feat(workflow): add trigger for assets added to an album

Introduces the AlbumAssetAdded workflow trigger and a new AssetAlbumV1
workflow type that carries album context (id, owner, name, description)
alongside the asset, mirroring AssetPersonV1. A new AlbumAssetAdd event
is emitted from AlbumService when assets are added to an album (both
addAssets and addAssetsToAlbums), and WorkflowExecutionService queues
the workflow trigger with the album id so steps can act on a specific
album.
This commit is contained in:
Claude
2026-06-02 20:30:31 +00:00
parent 9287fa08c6
commit a6669e0ca8
16 changed files with 147 additions and 36 deletions
+2
View File
@@ -2364,6 +2364,8 @@
"trash_page_title": "Trash ({count})",
"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_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow",
+3
View File
@@ -26,12 +26,14 @@ class WorkflowTrigger {
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
static const albumAssetAdded = WorkflowTrigger._(r'AlbumAssetAdded');
/// List of all possible values in this [enum][WorkflowTrigger].
static const values = <WorkflowTrigger>[
assetCreate,
assetMetadataExtraction,
personRecognized,
albumAssetAdded,
];
static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value);
@@ -73,6 +75,7 @@ class WorkflowTriggerTypeTransformer {
case r'AssetCreate': return WorkflowTrigger.assetCreate;
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
case r'AlbumAssetAdded': return WorkflowTrigger.albumAssetAdded;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+3
View File
@@ -25,11 +25,13 @@ class WorkflowType {
static const assetV1 = WorkflowType._(r'AssetV1');
static const assetPersonV1 = WorkflowType._(r'AssetPersonV1');
static const assetAlbumV1 = WorkflowType._(r'AssetAlbumV1');
/// List of all possible values in this [enum][WorkflowType].
static const values = <WorkflowType>[
assetV1,
assetPersonV1,
assetAlbumV1,
];
static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value);
@@ -70,6 +72,7 @@ class WorkflowTypeTypeTransformer {
switch (data) {
case r'AssetV1': return WorkflowType.assetV1;
case r'AssetPersonV1': return WorkflowType.assetPersonV1;
case r'AssetAlbumV1': return WorkflowType.assetAlbumV1;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+4 -2
View File
@@ -26811,7 +26811,8 @@
"enum": [
"AssetCreate",
"AssetMetadataExtraction",
"PersonRecognized"
"PersonRecognized",
"AlbumAssetAdded"
],
"type": "string"
},
@@ -26839,7 +26840,8 @@
"description": "Workflow type",
"enum": [
"AssetV1",
"AssetPersonV1"
"AssetPersonV1",
"AssetAlbumV1"
],
"type": "string"
},
+11
View File
@@ -11,6 +11,7 @@ type DeepPartial<T> = T extends Date
export type WorkflowEventMap = {
[WorkflowType.AssetV1]: AssetV1;
[WorkflowType.AssetPersonV1]: AssetPersonV1;
[WorkflowType.AssetAlbumV1]: AssetAlbumV1;
};
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
@@ -19,6 +20,7 @@ export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
PersonRecognized = 'PersonRecognized',
AlbumAssetAdded = 'AlbumAssetAdded',
}
export type WorkflowEventPayload<
@@ -128,3 +130,12 @@ export type AssetPersonV1 = AssetV1 & {
name: string;
};
};
export type AssetAlbumV1 = AssetV1 & {
album: {
id: string;
ownerId: string;
albumName: string;
description: string;
};
};
+4 -2
View File
@@ -7179,12 +7179,14 @@ export enum PartnerDirection {
}
export enum WorkflowType {
AssetV1 = "AssetV1",
AssetPersonV1 = "AssetPersonV1"
AssetPersonV1 = "AssetPersonV1",
AssetAlbumV1 = "AssetAlbumV1"
}
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
PersonRecognized = "PersonRecognized"
PersonRecognized = "PersonRecognized",
AlbumAssetAdded = "AlbumAssetAdded"
}
export enum QueueJobStatus {
Active = "active",
+1
View File
@@ -1172,6 +1172,7 @@ export const WorkflowTriggerSchema = z
export enum WorkflowType {
AssetV1 = 'AssetV1',
AssetPersonV1 = 'AssetPersonV1',
AssetAlbumV1 = 'AssetAlbumV1',
}
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
@@ -40,6 +40,7 @@ type EventMap = {
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
AlbumAssetAdd: [{ albumId: string; assetId: string; userId: string }];
// asset events
AssetCreate: [{ asset: Asset; file: UploadFile }];
@@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@@ -182,4 +183,15 @@ export class WorkflowRepository {
.where('id', '=', assetId)
.executeTakeFirstOrThrow();
}
getAlbumForWorkflow(albumId: string) {
return this.db
.selectFrom('album')
.innerJoin('album_user', 'album_user.albumId', 'album.id')
.where('album_user.role', '=', AlbumUserRole.Owner)
.select(['album.id', 'album_user.userId as ownerId', 'album.albumName', 'album.description'])
.where('album.id', '=', albumId)
.where('album.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
}
}
+13
View File
@@ -742,6 +742,9 @@ describe(AlbumService.name, () => {
owner.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
for (const assetId of [asset1.id, asset2.id, asset3.id]) {
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumAssetAdd', { albumId: album.id, assetId, userId: owner.id });
}
});
it('should not set the thumbnail if the album has one already', async () => {
@@ -1055,6 +1058,16 @@ describe(AlbumService.name, () => {
id: album2.id,
recipientId: owner2.id,
});
for (const { albumId, assetId } of [
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
{ albumId: album1.id, assetId: asset3.id },
{ albumId: album2.id, assetId: asset1.id },
{ albumId: album2.id, assetId: asset2.id },
{ albumId: album2.id, assetId: asset3.id },
]) {
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumAssetAdd', { albumId, assetId, userId: user.id });
}
});
it('should not allow a shared user with viewer access to add assets', async () => {
+10
View File
@@ -201,6 +201,12 @@ export class AlbumService extends BaseService {
}
}
for (const { id: assetId, success } of results) {
if (success) {
await this.eventRepository.emit('AlbumAssetAdd', { albumId: id, assetId, userId: auth.user.id });
}
}
return results;
}
@@ -261,6 +267,10 @@ export class AlbumService extends BaseService {
}
}
for (const { albumId, assetId } of albumAssetValues) {
await this.eventRepository.emit('AlbumAssetAdd', { albumId, assetId, userId: auth.user.id });
}
return results;
}
@@ -40,7 +40,7 @@ type ExecuteOptions<T extends WorkflowType> = {
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
};
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
type AssetTrigger = { userId: string; assetId: string; albumId?: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@@ -274,21 +274,57 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
@OnEvent({ name: 'AlbumAssetAdd' })
onAlbumAssetAdd({ userId, assetId, albumId }: ArgOf<'AlbumAssetAdd'>) {
return this.onAssetTrigger({ userId, assetId, albumId, trigger: WorkflowTrigger.AlbumAssetAdded });
}
private async onAssetTrigger({ userId, assetId, albumId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId, trigger },
data: { workflowId: workflow.id, assetId, albumId, trigger },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
handleAssetTrigger({ workflowId, assetId, albumId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
const writeAsset: ExecuteOptions<WorkflowType.AssetV1>['write'] = async (auth, changes) => {
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,
});
};
switch (type) {
case WorkflowType.AssetV1: {
return {
@@ -299,36 +335,28 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId,
};
},
write: async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
}
write: writeAsset,
} satisfies ExecuteOptions<typeof type>;
}
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,
case WorkflowType.AssetAlbumV1: {
if (!albumId) {
this.logger.error(`Misconfigured workflow ${workflowId}: missing albumId for type ${type}`);
return;
}
// 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,
});
return {
read: async () => {
const [asset, album] = await Promise.all([
this.workflowRepository.getForAssetV1(assetId),
this.workflowRepository.getAlbumForWorkflow(albumId),
]);
return {
data: { asset, album } as any,
authUserId: asset.ownerId,
};
},
write: writeAsset,
} satisfies ExecuteOptions<typeof type>;
}
}
+1 -1
View File
@@ -420,7 +420,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string; albumId?: string } }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
+15
View File
@@ -28,6 +28,21 @@ const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected:
types: [WorkflowType.AssetV1, WorkflowType.AssetPersonV1],
expected: true,
},
{
trigger: WorkflowTrigger.AlbumAssetAdded,
types: [WorkflowType.AssetAlbumV1],
expected: true,
},
{
trigger: WorkflowTrigger.AlbumAssetAdded,
types: [WorkflowType.AssetV1],
expected: false,
},
{
trigger: WorkflowTrigger.AssetCreate,
types: [WorkflowType.AssetAlbumV1],
expected: true,
},
];
describe(isMethodCompatible.name, () => {
+2
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.AssetAlbumV1],
};
export const getWorkflowTriggers = () =>
@@ -15,6 +16,7 @@ export const getWorkflowTriggers = () =>
const inferredMap: Record<WorkflowType, WorkflowType[]> = {
[WorkflowType.AssetV1]: [],
[WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1],
[WorkflowType.AssetAlbumV1]: [WorkflowType.AssetV1],
};
const withImpliedItems = (type: WorkflowType): WorkflowType[] => {
+6
View File
@@ -9,6 +9,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
case WorkflowTrigger.PersonRecognized: {
return $t('trigger_person_recognized');
}
case WorkflowTrigger.AlbumAssetAdded: {
return $t('trigger_album_asset_added');
}
default: {
return type;
}
@@ -23,6 +26,9 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
case WorkflowTrigger.PersonRecognized: {
return $t('trigger_person_recognized_description');
}
case WorkflowTrigger.AlbumAssetAdded: {
return $t('trigger_album_asset_added_description');
}
default: {
return type;
}