wip(server): asset added to album workflow trigger

This commit is contained in:
Ben Beckford 2026-07-29 13:50:58 -07:00
parent 4988c0a4c2
commit 223f4986f9
13 changed files with 145 additions and 39 deletions

View file

@ -2136,6 +2136,8 @@
"trash_page_info": "Trashed items will be permanently deleted after {days} days",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_album_asset_added": "Asset Added to Album",
"trigger_album_asset_added_description": "Triggered when an asset is added to an album",
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
"trigger_asset_uploaded": "Asset Upload",

View file

@ -19316,6 +19316,7 @@
"OcrQueueAll",
"Ocr",
"WorkflowAssetTrigger",
"WorkflowAlbumAssetTrigger",
"IntegrityUntrackedFilesQueueAll",
"IntegrityUntrackedFiles",
"IntegrityUntrackedRefresh",
@ -28031,7 +28032,8 @@
"description": "Plugin trigger type",
"enum": [
"AssetCreate",
"AssetMetadataExtraction"
"AssetMetadataExtraction",
"AlbumAssetAdded"
],
"type": "string"
},
@ -28058,7 +28060,8 @@
"WorkflowType": {
"description": "Workflow type",
"enum": [
"AssetV1"
"AssetV1",
"AlbumAssetV1"
],
"type": "string"
},

View file

@ -10,6 +10,7 @@ type DeepPartial<T> = T extends Date
export type WorkflowEventMap = {
[WorkflowType.AssetV1]: AssetV1;
[WorkflowType.AlbumAssetV1]: AlbumAssetV1;
// [WorkflowType.AssetPersonV1]: AssetPersonV1;
} & { [K in WorkflowType]: unknown };
@ -18,6 +19,7 @@ export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
AlbumAssetAdded = 'AlbumAssetAdded',
// PersonRecognized = 'PersonRecognized',
}
@ -123,6 +125,12 @@ export type AssetV1 = {
};
};
export type AlbumAssetV1 = AssetV1 & {
album: {
id: string;
};
};
// export type AssetPersonV1 = AssetV1 & {
// person: {
// id: string;

View file

@ -7419,11 +7419,13 @@ export enum PartnerDirection {
SharedWith = "shared-with"
}
export enum WorkflowType {
AssetV1 = "AssetV1"
AssetV1 = "AssetV1",
AlbumAssetV1 = "AlbumAssetV1"
}
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction"
AssetMetadataExtraction = "AssetMetadataExtraction",
AlbumAssetAdded = "AlbumAssetAdded"
}
export enum QueueJobStatus {
Active = "active",

View file

@ -903,6 +903,7 @@ export enum JobName {
// Workflow
WorkflowAssetTrigger = 'WorkflowAssetTrigger',
WorkflowAlbumAssetTrigger = 'WorkflowAlbumAssetTrigger',
// Integrity
IntegrityUntrackedFilesQueueAll = 'IntegrityUntrackedFilesQueueAll',
@ -1225,6 +1226,7 @@ export const WorkflowTriggerSchema = z
export enum WorkflowType {
AssetV1 = 'AssetV1',
AlbumAssetV1 = 'AlbumAssetV1',
// AssetPersonV1 = 'AssetPersonV1',
}

View file

@ -9,6 +9,7 @@ select
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
"workflow"."ownerId",
(
select
coalesce(json_agg(agg), '[]')
@ -43,6 +44,7 @@ select
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
"workflow"."ownerId",
(
select
coalesce(json_agg(agg), '[]')

View file

@ -40,6 +40,7 @@ type EventMap = {
// album events
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
AlbumAssetsAdded: [{ albumId: string; userIds: string[]; recipientIds: string[]; assetIds: string[] }];
// asset events
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];

View file

@ -26,6 +26,7 @@ export class WorkflowRepository {
'workflow.enabled',
'workflow.createdAt',
'workflow.updatedAt',
'workflow.ownerId',
])
.select((eb) => [
jsonArrayFrom(

View file

@ -193,6 +193,12 @@ export class AlbumService extends BaseService {
const userIds = album.albumUsers.map(({ user }) => user.id);
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
await this.eventRepository.emit('AlbumUpdate', { id, userIds, recipientIds });
await this.eventRepository.emit('AlbumAssetsAdded', {
albumId: id,
userIds,
recipientIds,
assetIds: results.filter((a) => a.success).map((a) => a.id),
});
}
return results;
@ -221,7 +227,8 @@ export class AlbumService extends BaseService {
}
const albumAssetValues: { albumId: string; assetId: string }[] = [];
const events: { id: string; userIds: string[]; recipientIds: string[] }[] = [];
const updateEvents: { id: string; userIds: string[]; recipientIds: string[] }[] = [];
const addedEvents: { albumId: string; userIds: string[]; recipientIds: string[]; assetIds: string[] }[] = [];
for (const albumId of allowedAlbumIds) {
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
const notPresentAssetIds = [...allowedAssetIds.difference(existingAssetIds)];
@ -246,13 +253,17 @@ export class AlbumService extends BaseService {
);
const userIds = album.albumUsers.map(({ user }) => user.id);
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, userIds, recipientIds });
updateEvents.push({ id: albumId, userIds, recipientIds });
addedEvents.push({ albumId, userIds, recipientIds, assetIds: notPresentAssetIds });
}
await this.albumRepository.addAssetIdsToAlbums(albumAssetValues);
for (const event of events) {
for (const event of updateEvents) {
await this.eventRepository.emit('AlbumUpdate', event);
}
for (const event of addedEvents) {
await this.eventRepository.emit('AlbumAssetsAdded', event);
}
return results;
}

View file

@ -27,7 +27,7 @@ import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
import { JobItem, JobOf } from 'src/types';
const dummy = () => {
throw new Error(
@ -42,6 +42,8 @@ type ExecuteOptions<T extends WorkflowType> = {
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
type AlbumAssetTrigger = { userIds: string[]; albumId: string; assetIds: string[]; trigger: WorkflowTrigger };
type HostContext = {
allowedHosts: string[];
};
@ -309,6 +311,16 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
@OnEvent({ name: 'AlbumAssetsAdded' })
onAlbumAssetsAdded({ albumId, userIds, recipientIds, assetIds }: ArgOf<'AlbumAssetsAdded'>) {
return this.onAlbumAssetTrigger({
albumId,
userIds: [...userIds, ...recipientIds],
assetIds,
trigger: WorkflowTrigger.AlbumAssetAdded,
});
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
@ -319,11 +331,64 @@ export class WorkflowExecutionService extends BaseService {
);
}
private async onAlbumAssetTrigger({ albumId, userIds, assetIds, trigger }: AlbumAssetTrigger) {
let jobs: JobItem[] = [];
for (const userId of userIds) {
const items = await this.workflowRepository.search({ userId, trigger });
if (!items.length) {
continue;
}
let batch: JobItem[] = items.flatMap(({ id: workflowId }) =>
assetIds.map((assetId) => ({
name: JobName.WorkflowAlbumAssetTrigger,
data: { workflowId, assetId, albumId, trigger },
})),
);
jobs.push(...batch);
}
await this.jobRepository.queueAll(jobs);
}
private writeAssetV1(assetId: string, type: WorkflowType) {
const assetService = BaseService.create(AssetService, this);
return async (auth: AuthDto, changes: WorkflowChanges<typeof type>) => {
const asset = changes.asset;
if (!asset) {
return;
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
};
}
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) {
case WorkflowType.AssetV1: {
return {
@ -334,35 +399,35 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId,
};
},
write: this.writeAssetV1(assetId, type),
} satisfies ExecuteOptions<typeof type>;
}
}
});
}
@OnJob({ name: JobName.WorkflowAlbumAssetTrigger, queue: QueueName.Workflow })
handleAlbumAssetTrigger({ workflowId, assetId, albumId }: JobOf<JobName.WorkflowAlbumAssetTrigger>) {
return this.execute(workflowId, (type) => {
switch (type) {
case WorkflowType.AssetV1: {
return {
read: async () => {
const asset = await this.workflowRepository.getForAssetV1(assetId);
const workflow = await this.workflowRepository.get(workflowId);
return {
data: { asset, album: { id: albumId } } as any,
authUserId: workflow!.ownerId,
};
},
write: async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
const asset = await this.workflowRepository.getForAssetV1(assetId);
const workflow = await this.workflowRepository.get(workflowId);
if (asset.ownerId === workflow!.ownerId) {
await this.writeAssetV1(assetId, type)(auth, changes);
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
},
} satisfies ExecuteOptions<typeof type>;
}

View file

@ -458,6 +458,7 @@ export type JobItem =
// Workflow
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
| { name: JobName.WorkflowAlbumAssetTrigger; data: { workflowId: string; assetId: string; albumId: string } }
// Integrity
| { name: JobName.IntegrityUntrackedFilesQueueAll; data?: IIntegrityJob }

View file

@ -6,6 +6,7 @@ export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
// [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
[WorkflowTrigger.AlbumAssetAdded]: [WorkflowType.AlbumAssetV1],
};
export const getWorkflowTriggers = () =>
@ -14,6 +15,7 @@ export const getWorkflowTriggers = () =>
/** some types extend other types and have implied compatibility */
const inferredMap: Record<WorkflowType, WorkflowType[]> = {
[WorkflowType.AssetV1]: [],
[WorkflowType.AlbumAssetV1]: [WorkflowType.AssetV1],
// [WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1],
};
@ -29,8 +31,8 @@ const withImpliedItems = (type: WorkflowType): WorkflowType[] => {
export const isMethodCompatible = (pluginMethod: { types: WorkflowType[] }, trigger: WorkflowTrigger) => {
const validTypes = triggerMap[trigger];
const pluginCompatibility = pluginMethod.types.map((type) => withImpliedItems(type));
for (const requested of validTypes) {
const pluginCompatibility = validTypes.map((type) => withImpliedItems(type));
for (const requested of pluginMethod.types) {
for (const pluginCompatibilityGroup of pluginCompatibility) {
if (pluginCompatibilityGroup.includes(requested)) {
return true;

View file

@ -13,6 +13,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction');
}
case WorkflowTrigger.AlbumAssetAdded: {
return $t('trigger_album_asset_added');
}
default: {
return type;
}
@ -30,6 +33,9 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction_description');
}
case WorkflowTrigger.AlbumAssetAdded: {
return $t('trigger_album_asset_added_description');
}
}
};