From 08254ee31fcb00e3683f530ee6a0fe2cdf58dc27 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 7 Oct 2025 22:51:58 +0900 Subject: [PATCH 01/14] feat: modify schema, add assetIds to activity and update related constraints and functions --- server/src/database.ts | 1 + server/src/schema/functions.ts | 17 ++++++ .../1759817260868-AddAssetIdsIntoActivity.ts | 54 +++++++++++++++++++ server/src/schema/tables/activity.table.ts | 6 ++- server/src/schema/tables/album-asset.table.ts | 8 ++- 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 server/src/schema/migrations/1759817260868-AddAssetIdsIntoActivity.ts diff --git a/server/src/database.ts b/server/src/database.ts index f472c643ee..b587f1472d 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -63,6 +63,7 @@ export type Activity = { userId: string; user: User; assetId: string | null; + assetIds: string[] | null; comment: string | null; isLiked: boolean; updateId: string; diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index e255742b5d..0760679b28 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -145,6 +145,23 @@ export const album_asset_delete_audit = registerFunction({ END`, }); +export const activity_assetids_update_on_albumasset_delete = registerFunction({ + name: 'activity_assetids_update_on_albumasset_delete', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + DECLARE + r RECORD; + BEGIN + FOR r IN SELECT "albumsId", "assetsId" FROM old LOOP + UPDATE activity + SET "assetIds" = array_remove("assetIds", r."assetsId") + WHERE "albumId" = r."albumsId" AND "assetIds" IS NOT NULL AND r."assetsId" = ANY("assetIds"); + END LOOP; + RETURN NULL; + END;`, +}); + export const album_user_delete_audit = registerFunction({ name: 'album_user_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/migrations/1759817260868-AddAssetIdsIntoActivity.ts b/server/src/schema/migrations/1759817260868-AddAssetIdsIntoActivity.ts new file mode 100644 index 0000000000..17acc5da50 --- /dev/null +++ b/server/src/schema/migrations/1759817260868-AddAssetIdsIntoActivity.ts @@ -0,0 +1,54 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_like_check";`.execute(db); + await sql`ALTER TABLE "activity" ADD "assetIds" uuid[];`.execute(db); + await sql`CREATE INDEX "activity_assetIds_idx" ON "activity" ("assetIds");`.execute(db); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_check" CHECK ((comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false) OR (comment IS NULL AND "isLiked" = false AND "assetIds" IS NOT NULL));`.execute( + db, + ); + await sql`CREATE OR REPLACE FUNCTION activity_assetids_update_on_albumasset_delete() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + DECLARE + r RECORD; + BEGIN + FOR r IN SELECT "albumsId", "assetsId" FROM old LOOP + UPDATE activity + SET "assetIds" = array_remove("assetIds", r."assetsId") + WHERE "albumId" = r."albumsId" AND "assetIds" IS NOT NULL AND r."assetsId" = ANY("assetIds"); + END LOOP; + RETURN NULL; + END; + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "activity_assetids_update_on_albumasset_delete" + AFTER DELETE ON "album_asset" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() <= 1) + EXECUTE FUNCTION activity_assetids_update_on_albumasset_delete();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_activity_assetids_update_on_albumasset_delete', '{"type":"function","name":"activity_assetids_update_on_albumasset_delete","sql":"CREATE OR REPLACE FUNCTION activity_assetids_update_on_albumasset_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE activity\\n SET \\"assetIds\\" = array_remove(\\"assetIds\\", OLD.\\"assetsId\\")\\n WHERE \\"albumId\\" = OLD.\\"albumsId\\" AND \\"assetIds\\" IS NOT NULL AND OLD.\\"assetsId\\" = ANY(\\"assetIds\\");\\n RETURN NULL;\\n END;\\n $$;"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_activity_assetids_update_on_albumasset_delete', '{"type":"trigger","name":"activity_assetids_update_on_albumasset_delete","sql":"CREATE OR REPLACE TRIGGER \\"activity_assetids_update_on_albumasset_delete\\"\\n AFTER DELETE ON \\"album_asset\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION activity_assetids_update_on_albumasset_delete();"}'::jsonb);`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "activity_assetids_update_on_albumasset_delete" ON "album_asset";`.execute(db); + await sql`DROP FUNCTION activity_assetids_update_on_albumasset_delete;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_activity_assetids_update_on_albumasset_delete';`.execute( + db, + ); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_activity_assetids_update_on_albumasset_delete';`.execute( + db, + ); + await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_check";`.execute(db); + await sql`DROP INDEX "activity_assetIds_idx";`.execute(db); + await sql`ALTER TABLE "activity" DROP COLUMN "assetIds";`.execute(db); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_like_check" CHECK (((((comment IS NULL) AND ("isLiked" = true)) OR ((comment IS NOT NULL) AND ("isLiked" = false)))));`.execute( + db, + ); +} diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index 128cf2eabd..7da65628ff 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -26,8 +26,8 @@ import { where: '("isLiked" = true)', }) @Check({ - name: 'activity_like_check', - expression: `(comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false)`, + name: 'activity_check', + expression: `(comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false) OR (comment IS NULL AND "isLiked" = false AND "assetIds" IS NOT NULL)`, }) @ForeignKeyConstraint({ columns: ['albumId', 'assetId'], @@ -55,6 +55,8 @@ export class ActivityTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) assetId!: string | null; + assetIds!: string[] | null; + @Column({ type: 'text', default: null }) comment!: string | null; diff --git a/server/src/schema/tables/album-asset.table.ts b/server/src/schema/tables/album-asset.table.ts index c34546c3f3..3ba96a5475 100644 --- a/server/src/schema/tables/album-asset.table.ts +++ b/server/src/schema/tables/album-asset.table.ts @@ -1,5 +1,5 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { album_asset_delete_audit } from 'src/schema/functions'; +import { activity_assetids_update_on_albumasset_delete, album_asset_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { @@ -20,6 +20,12 @@ import { referencingOldTableAs: 'old', when: 'pg_trigger_depth() <= 1', }) +@AfterDeleteTrigger({ + scope: 'statement', + function: activity_assetids_update_on_albumasset_delete, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() <= 1', +}) export class AlbumAssetTable { @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) albumsId!: string; From cce343b4f9c2e2a53112be40929927640318446a Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 7 Oct 2025 22:53:17 +0900 Subject: [PATCH 02/14] feat: add assetIds to ActivityResponseDto and ActivityCreateDto, update reaction type logic --- server/src/dtos/activity.dto.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/dtos/activity.dto.ts b/server/src/dtos/activity.dto.ts index 4b11a16e14..9a9f70c8b8 100644 --- a/server/src/dtos/activity.dto.ts +++ b/server/src/dtos/activity.dto.ts @@ -7,6 +7,7 @@ import { ValidateEnum, ValidateUUID } from 'src/validation'; export enum ReactionType { COMMENT = 'comment', LIKE = 'like', + ASSET = 'asset', } export enum ReactionLevel { @@ -23,6 +24,7 @@ export class ActivityResponseDto { type!: ReactionType; user!: UserResponseDto; assetId!: string | null; + assetIds!: string[] | null; comment?: string | null; } @@ -63,15 +65,18 @@ export class ActivityCreateDto extends ActivityDto { @IsNotEmpty() @IsString() comment?: string; + + assetIds?: string[] | undefined; } export const mapActivity = (activity: Activity): ActivityResponseDto => { return { id: activity.id, assetId: activity.assetId, + assetIds: activity.assetIds, createdAt: activity.createdAt, comment: activity.comment, - type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, + type: activity.assetIds ? ReactionType.ASSET : activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, user: mapUser(activity.user), }; }; From 95f8deee21f8d021e19603964039bbc886c76d50 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 7 Oct 2025 22:54:56 +0900 Subject: [PATCH 03/14] make open-api --- .../openapi/lib/model/activity_create_dto.dart | 11 ++++++++++- .../lib/model/activity_response_dto.dart | 16 +++++++++++++++- mobile/openapi/lib/model/reaction_type.dart | 3 +++ open-api/immich-openapi-specs.json | 17 ++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 5 ++++- 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart index ce4b4a0176..72b59b3f7e 100644 --- a/mobile/openapi/lib/model/activity_create_dto.dart +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -15,6 +15,7 @@ class ActivityCreateDto { ActivityCreateDto({ required this.albumId, this.assetId, + this.assetIds = const [], this.comment, required this.type, }); @@ -29,6 +30,8 @@ class ActivityCreateDto { /// String? assetId; + List assetIds; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -43,6 +46,7 @@ class ActivityCreateDto { bool operator ==(Object other) => identical(this, other) || other is ActivityCreateDto && other.albumId == albumId && other.assetId == assetId && + _deepEquality.equals(other.assetIds, assetIds) && other.comment == comment && other.type == type; @@ -51,11 +55,12 @@ class ActivityCreateDto { // ignore: unnecessary_parenthesis (albumId.hashCode) + (assetId == null ? 0 : assetId!.hashCode) + + (assetIds.hashCode) + (comment == null ? 0 : comment!.hashCode) + (type.hashCode); @override - String toString() => 'ActivityCreateDto[albumId=$albumId, assetId=$assetId, comment=$comment, type=$type]'; + String toString() => 'ActivityCreateDto[albumId=$albumId, assetId=$assetId, assetIds=$assetIds, comment=$comment, type=$type]'; Map toJson() { final json = {}; @@ -65,6 +70,7 @@ class ActivityCreateDto { } else { // json[r'assetId'] = null; } + json[r'assetIds'] = this.assetIds; if (this.comment != null) { json[r'comment'] = this.comment; } else { @@ -85,6 +91,9 @@ class ActivityCreateDto { return ActivityCreateDto( albumId: mapValueOfType(json, r'albumId')!, assetId: mapValueOfType(json, r'assetId'), + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], comment: mapValueOfType(json, r'comment'), type: ReactionType.fromJson(json[r'type'])!, ); diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index 25fb0f53f8..3875a4846f 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -14,6 +14,7 @@ class ActivityResponseDto { /// Returns a new [ActivityResponseDto] instance. ActivityResponseDto({ required this.assetId, + this.assetIds = const [], this.comment, required this.createdAt, required this.id, @@ -23,6 +24,8 @@ class ActivityResponseDto { String? assetId; + List? assetIds; + String? comment; DateTime createdAt; @@ -36,6 +39,7 @@ class ActivityResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto && other.assetId == assetId && + _deepEquality.equals(other.assetIds, assetIds) && other.comment == comment && other.createdAt == createdAt && other.id == id && @@ -46,6 +50,7 @@ class ActivityResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (assetId == null ? 0 : assetId!.hashCode) + + (assetIds == null ? 0 : assetIds!.hashCode) + (comment == null ? 0 : comment!.hashCode) + (createdAt.hashCode) + (id.hashCode) + @@ -53,7 +58,7 @@ class ActivityResponseDto { (user.hashCode); @override - String toString() => 'ActivityResponseDto[assetId=$assetId, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]'; + String toString() => 'ActivityResponseDto[assetId=$assetId, assetIds=$assetIds, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]'; Map toJson() { final json = {}; @@ -62,6 +67,11 @@ class ActivityResponseDto { } else { // json[r'assetId'] = null; } + if (this.assetIds != null) { + json[r'assetIds'] = this.assetIds; + } else { + // json[r'assetIds'] = null; + } if (this.comment != null) { json[r'comment'] = this.comment; } else { @@ -84,6 +94,9 @@ class ActivityResponseDto { return ActivityResponseDto( assetId: mapValueOfType(json, r'assetId'), + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], comment: mapValueOfType(json, r'comment'), createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, @@ -137,6 +150,7 @@ class ActivityResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'assetId', + 'assetIds', 'createdAt', 'id', 'type', diff --git a/mobile/openapi/lib/model/reaction_type.dart b/mobile/openapi/lib/model/reaction_type.dart index 4c788138fb..2db2aa8b23 100644 --- a/mobile/openapi/lib/model/reaction_type.dart +++ b/mobile/openapi/lib/model/reaction_type.dart @@ -25,11 +25,13 @@ class ReactionType { static const comment = ReactionType._(r'comment'); static const like = ReactionType._(r'like'); + static const asset = ReactionType._(r'asset'); /// List of all possible values in this [enum][ReactionType]. static const values = [ comment, like, + asset, ]; static ReactionType? fromJson(dynamic value) => ReactionTypeTypeTransformer().decode(value); @@ -70,6 +72,7 @@ class ReactionTypeTypeTransformer { switch (data) { case r'comment': return ReactionType.comment; case r'like': return ReactionType.like; + case r'asset': return ReactionType.asset; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b574bc6624..ef8409e6f2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9977,6 +9977,12 @@ "format": "uuid", "type": "string" }, + "assetIds": { + "items": { + "type": "string" + }, + "type": "array" + }, "comment": { "type": "string" }, @@ -10000,6 +10006,13 @@ "nullable": true, "type": "string" }, + "assetIds": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, "comment": { "nullable": true, "type": "string" @@ -10024,6 +10037,7 @@ }, "required": [ "assetId", + "assetIds", "createdAt", "id", "type", @@ -13687,7 +13701,8 @@ "ReactionType": { "enum": [ "comment", - "like" + "like", + "asset" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c8a69dfe8c..547a521425 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -24,6 +24,7 @@ export type UserResponseDto = { }; export type ActivityResponseDto = { assetId: string | null; + assetIds: string[] | null; comment?: string | null; createdAt: string; id: string; @@ -33,6 +34,7 @@ export type ActivityResponseDto = { export type ActivityCreateDto = { albumId: string; assetId?: string; + assetIds?: string[]; comment?: string; "type": ReactionType; }; @@ -4626,7 +4628,8 @@ export enum ReactionLevel { } export enum ReactionType { Comment = "comment", - Like = "like" + Like = "like", + Asset = "asset" } export enum UserAvatarColor { Primary = "primary", From ce7e1a61a5ab3e23375df46d0852cd26e2b88dac Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 7 Oct 2025 22:55:58 +0900 Subject: [PATCH 04/14] feat: add AlbumAssets event handling and emit logic in Activity and Album services --- server/src/repositories/activity.repository.ts | 6 +++++- server/src/repositories/event.repository.ts | 1 + server/src/services/activity.service.ts | 16 +++++++++++++++- server/src/services/album.service.ts | 9 +++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 1a1104b118..22d9fcd041 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -81,7 +81,11 @@ export class ActivityRepository { const result = await this.db .selectFrom('activity') .select((eb) => [ - eb.fn.countAll().filterWhere('activity.isLiked', '=', false).as('comments'), + eb.fn + .countAll() + .filterWhere('activity.isLiked', '=', false) + .filterWhere('activity.assetIds', 'is', null) + .as('comments'), eb.fn.countAll().filterWhere('activity.isLiked', '=', true).as('likes'), ]) .innerJoin('user', (join) => join.onRef('user.id', '=', 'activity.userId').on('user.deletedAt', 'is', null)) diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index ec4c8a8f52..b18090ba09 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -51,6 +51,7 @@ type EventMap = { // album events AlbumUpdate: [{ id: string; recipientId: string }]; AlbumInvite: [{ id: string; userId: string }]; + AlbumAssets: [{ id: string; assetIds: string[]; userId: string }]; // asset events AssetTag: [{ assetId: string }]; diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index b1c25f8286..9354b66bc3 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Activity } from 'src/database'; +import { OnEvent } from 'src/decorators'; import { ActivityCreateDto, ActivityDto, @@ -26,7 +27,7 @@ export class ActivityService extends BaseService { isLiked: dto.type && dto.type === ReactionType.LIKE, }); - return activities.map((activity) => mapActivity(activity)); + return activities.filter((a) => !a.assetIds || a.assetIds?.length > 0).map((activity) => mapActivity(activity)); } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { @@ -40,6 +41,7 @@ export class ActivityService extends BaseService { const common = { userId: auth.user.id, assetId: dto.assetId, + assetIds: dto.assetIds, albumId: dto.albumId, }; @@ -72,4 +74,16 @@ export class ActivityService extends BaseService { await this.requireAccess({ auth, permission: Permission.ActivityDelete, ids: [id] }); await this.activityRepository.delete(id); } + + @OnEvent({ name: 'AlbumAssets' }) + async handleAlbumAssetsEvent(payload: { id: string; assetIds: string[]; userId: string }) { + this.logger.log( + `AlbumAssets event received: albumId=${payload.id}, userId=${payload.userId}, assetIds=${payload.assetIds.join(', ')}`, + ); + await this.create({ user: { id: payload.userId } } as AuthDto, { + assetIds: payload.assetIds, + albumId: payload.id, + type: ReactionType.ASSET, + }); + } } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index d7b857d666..43d66b0770 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -183,6 +183,9 @@ export class AlbumService extends BaseService { for (const recipientId of allUsersExceptUs) { await this.eventRepository.emit('AlbumUpdate', { id, recipientId }); } + + const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); + await this.eventRepository.emit('AlbumAssets', { id, assetIds: newAssetIds, userId: auth.user.id }); } return results; @@ -222,6 +225,12 @@ export class AlbumService extends BaseService { results.error = undefined; results.success = true; + await this.eventRepository.emit('AlbumAssets', { + id: albumId, + assetIds: notPresentAssetIds, + userId: auth.user.id, + }); + for (const assetId of notPresentAssetIds) { albumAssetValues.push({ albumsId: albumId, assetsId: assetId }); } From f999309474dcf1028e3d4019510ed69163997529 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 7 Oct 2025 22:57:37 +0900 Subject: [PATCH 05/14] feat(web): show added assets activity --- .../asset-viewer/activity-viewer.svelte | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index e37968b982..5eec86e6c8 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -239,6 +239,41 @@ {/if} + {:else if reaction.type === ReactionType.Asset} +
+
+ +
+
+ {$t('asset_added_to_album')} +
+ {#if assetId === undefined && reaction.assetIds && reaction.assetIds.length > 0} + + {/if} +
+ {#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1} +
+ {timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))} +
+ {/if} {/if} {/each} From 3418597edad7e4fe465bac7de7fd05271edc128d Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 00:21:02 +0900 Subject: [PATCH 06/14] improve: upsertAssetIds --- .../src/repositories/activity.repository.ts | 19 +++++++++ server/src/services/activity.service.ts | 42 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 22d9fcd041..9dcf7e2833 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -65,6 +65,25 @@ export class ActivityRepository { .executeTakeFirstOrThrow(); } + async updateAssetIds(id: string, assetIds: string[]) { + await this.db.updateTable('activity').set({ assetIds }).where('id', '=', id).execute(); + } + + async findRecentAssetIdsActivity(albumId: string, userId: string, minutes = 15) { + const since = new Date(Date.now() - minutes * 60 * 1000); + return this.db + .selectFrom('activity') + .selectAll() + .where('albumId', '=', albumId) + .where('userId', '=', userId) + .where('assetId', 'is', null) + .where('isLiked', '=', false) + .where('assetIds', 'is not', null) + .where(({ eb }) => eb('createdAt', '>=', since)) + .orderBy('createdAt', 'desc') + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID] }) async delete(id: string) { await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute(); diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index 9354b66bc3..54ea7b8276 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -70,6 +70,43 @@ export class ActivityService extends BaseService { return { duplicate, value: mapActivity(activity) }; } + async upsertAssetIds(auth: AuthDto, dto: ActivityCreateDto): Promise> { + await this.requireAccess({ auth, permission: Permission.ActivityCreate, ids: [dto.albumId] }); + + const recent = await this.activityRepository.findRecentAssetIdsActivity(dto.albumId, auth.user.id, 15); + this.logger.debug( + `Recent activity for user ${auth.user.id} in album ${dto.albumId}: ${recent ? recent.id : 'none'}`, + ); + + let activity: Activity; + let duplicate = false; + + if (recent) { + // Merge assetIds (remove duplicates) + const oldIds: string[] = Array.isArray(recent.assetIds) ? recent.assetIds : []; + const newIds: string[] = Array.isArray(dto.assetIds) ? dto.assetIds : []; + const merged = Array.from(new Set([...oldIds, ...newIds])); + await this.activityRepository.updateAssetIds(recent.id, merged); + // get the updated record (including user info) + const [updated] = await this.activityRepository.search({ + userId: auth.user.id, + albumId: dto.albumId, + assetId: null, + isLiked: false, + }); + activity = updated ?? { ...recent, assetIds: merged }; + duplicate = true; + + return { duplicate, value: mapActivity(activity) }; + } + + return await this.create({ user: { id: auth.user.id } } as AuthDto, { + assetIds: dto.assetIds, + albumId: dto.albumId, + type: ReactionType.ASSET, + }); + } + async delete(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.ActivityDelete, ids: [id] }); await this.activityRepository.delete(id); @@ -77,10 +114,11 @@ export class ActivityService extends BaseService { @OnEvent({ name: 'AlbumAssets' }) async handleAlbumAssetsEvent(payload: { id: string; assetIds: string[]; userId: string }) { - this.logger.log( + this.logger.debug( `AlbumAssets event received: albumId=${payload.id}, userId=${payload.userId}, assetIds=${payload.assetIds.join(', ')}`, ); - await this.create({ user: { id: payload.userId } } as AuthDto, { + + await this.upsertAssetIds({ user: { id: payload.userId } } as AuthDto, { assetIds: payload.assetIds, albumId: payload.id, type: ReactionType.ASSET, From d39e361a7b534fdebd06e16c3ba31a39a61b731c Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 01:46:19 +0900 Subject: [PATCH 07/14] fix: lint:fix --- server/src/services/activity.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index 54ea7b8276..fedcd93fe7 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -85,7 +85,7 @@ export class ActivityService extends BaseService { // Merge assetIds (remove duplicates) const oldIds: string[] = Array.isArray(recent.assetIds) ? recent.assetIds : []; const newIds: string[] = Array.isArray(dto.assetIds) ? dto.assetIds : []; - const merged = Array.from(new Set([...oldIds, ...newIds])); + const merged = [...new Set([...oldIds, ...newIds])]; await this.activityRepository.updateAssetIds(recent.id, merged); // get the updated record (including user info) const [updated] = await this.activityRepository.search({ From 5d2b2008f809ea4f5e1140955426e60b37b3ba53 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 02:13:48 +0200 Subject: [PATCH 08/14] fix: pnpm check:svelte --- web/src/lib/components/asset-viewer/activity-viewer.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 5eec86e6c8..08802fae20 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -74,6 +74,7 @@ const deleteMessages: Record = { [ReactionType.Comment]: $t('comment_deleted'), [ReactionType.Like]: $t('like_deleted'), + [ReactionType.Asset]: '', // can not delete asset activity }; notificationController.show({ message: deleteMessages[reaction.type], From 9dc2e73e381736e3e546564521cde91cb1f56a7b Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 02:42:40 +0200 Subject: [PATCH 09/14] fix: schema migration fails --- ...s => 1759883700247-AlbumAssetsActivity.ts} | 24 +++++++++---------- server/src/schema/tables/activity.table.ts | 1 + 2 files changed, 12 insertions(+), 13 deletions(-) rename server/src/schema/migrations/{1759817260868-AddAssetIdsIntoActivity.ts => 1759883700247-AlbumAssetsActivity.ts} (86%) diff --git a/server/src/schema/migrations/1759817260868-AddAssetIdsIntoActivity.ts b/server/src/schema/migrations/1759883700247-AlbumAssetsActivity.ts similarity index 86% rename from server/src/schema/migrations/1759817260868-AddAssetIdsIntoActivity.ts rename to server/src/schema/migrations/1759883700247-AlbumAssetsActivity.ts index 17acc5da50..2b80e8159d 100644 --- a/server/src/schema/migrations/1759817260868-AddAssetIdsIntoActivity.ts +++ b/server/src/schema/migrations/1759883700247-AlbumAssetsActivity.ts @@ -1,12 +1,6 @@ import { Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { - await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_like_check";`.execute(db); - await sql`ALTER TABLE "activity" ADD "assetIds" uuid[];`.execute(db); - await sql`CREATE INDEX "activity_assetIds_idx" ON "activity" ("assetIds");`.execute(db); - await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_check" CHECK ((comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false) OR (comment IS NULL AND "isLiked" = false AND "assetIds" IS NOT NULL));`.execute( - db, - ); await sql`CREATE OR REPLACE FUNCTION activity_assetids_update_on_albumasset_delete() RETURNS TRIGGER LANGUAGE PLPGSQL @@ -22,22 +16,32 @@ export async function up(db: Kysely): Promise { RETURN NULL; END; $$;`.execute(db); + await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_like_check";`.execute(db); + await sql`ALTER TABLE "activity" ADD "assetIds" uuid[];`.execute(db); await sql`CREATE OR REPLACE TRIGGER "activity_assetids_update_on_albumasset_delete" AFTER DELETE ON "album_asset" REFERENCING OLD TABLE AS "old" FOR EACH STATEMENT WHEN (pg_trigger_depth() <= 1) EXECUTE FUNCTION activity_assetids_update_on_albumasset_delete();`.execute(db); - await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_activity_assetids_update_on_albumasset_delete', '{"type":"function","name":"activity_assetids_update_on_albumasset_delete","sql":"CREATE OR REPLACE FUNCTION activity_assetids_update_on_albumasset_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE activity\\n SET \\"assetIds\\" = array_remove(\\"assetIds\\", OLD.\\"assetsId\\")\\n WHERE \\"albumId\\" = OLD.\\"albumsId\\" AND \\"assetIds\\" IS NOT NULL AND OLD.\\"assetsId\\" = ANY(\\"assetIds\\");\\n RETURN NULL;\\n END;\\n $$;"}'::jsonb);`.execute( + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_activity_assetids_update_on_albumasset_delete', '{"type":"function","name":"activity_assetids_update_on_albumasset_delete","sql":"CREATE OR REPLACE FUNCTION activity_assetids_update_on_albumasset_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n DECLARE\\n r RECORD;\\n BEGIN\\n FOR r IN SELECT \\"albumsId\\", \\"assetsId\\" FROM old LOOP\\n UPDATE activity\\n SET \\"assetIds\\" = array_remove(\\"assetIds\\", r.\\"assetsId\\")\\n WHERE \\"albumId\\" = r.\\"albumsId\\" AND \\"assetIds\\" IS NOT NULL AND r.\\"assetsId\\" = ANY(\\"assetIds\\");\\n END LOOP;\\n RETURN NULL;\\n END;\\n $$;"}'::jsonb);`.execute( db, ); await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_activity_assetids_update_on_albumasset_delete', '{"type":"trigger","name":"activity_assetids_update_on_albumasset_delete","sql":"CREATE OR REPLACE TRIGGER \\"activity_assetids_update_on_albumasset_delete\\"\\n AFTER DELETE ON \\"album_asset\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION activity_assetids_update_on_albumasset_delete();"}'::jsonb);`.execute( db, ); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_check" CHECK ((comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false) OR (comment IS NULL AND "isLiked" = false AND "assetIds" IS NOT NULL));`.execute( + db, + ); } export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_check";`.execute(db); await sql`DROP TRIGGER "activity_assetids_update_on_albumasset_delete" ON "album_asset";`.execute(db); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_like_check" CHECK (((((comment IS NULL) AND ("isLiked" = true)) OR ((comment IS NOT NULL) AND ("isLiked" = false)))));`.execute( + db, + ); + await sql`ALTER TABLE "activity" DROP COLUMN "assetIds";`.execute(db); await sql`DROP FUNCTION activity_assetids_update_on_albumasset_delete;`.execute(db); await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_activity_assetids_update_on_albumasset_delete';`.execute( db, @@ -45,10 +49,4 @@ export async function down(db: Kysely): Promise { await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_activity_assetids_update_on_albumasset_delete';`.execute( db, ); - await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_check";`.execute(db); - await sql`DROP INDEX "activity_assetIds_idx";`.execute(db); - await sql`ALTER TABLE "activity" DROP COLUMN "assetIds";`.execute(db); - await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_like_check" CHECK (((((comment IS NULL) AND ("isLiked" = true)) OR ((comment IS NOT NULL) AND ("isLiked" = false)))));`.execute( - db, - ); } diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index 7da65628ff..34ba8cb23c 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -55,6 +55,7 @@ export class ActivityTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) assetId!: string | null; + @Column({ array: true, type: 'uuid', default: null }) assetIds!: string[] | null; @Column({ type: 'text', default: null }) From c02fd574a39012a7afb95d5b95dd8aa287d15df4 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 03:18:22 +0200 Subject: [PATCH 10/14] fix: e2e test fails --- e2e/src/api/specs/activity.e2e-spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts index 9ce9b4b916..1d8bb447fd 100644 --- a/e2e/src/api/specs/activity.e2e-spec.ts +++ b/e2e/src/api/specs/activity.e2e-spec.ts @@ -174,6 +174,7 @@ describe('/activities', () => { expect(body).toEqual({ id: expect.any(String), assetId: null, + assetIds: null, createdAt: expect.any(String), type: 'comment', comment: 'This is my first comment', @@ -190,6 +191,7 @@ describe('/activities', () => { expect(body).toEqual({ id: expect.any(String), assetId: null, + assetIds: null, createdAt: expect.any(String), type: 'like', comment: null, @@ -235,6 +237,7 @@ describe('/activities', () => { expect(body).toEqual({ id: expect.any(String), assetId: asset.id, + assetIds: null, createdAt: expect.any(String), type: 'comment', comment: 'This is my first comment', @@ -251,6 +254,7 @@ describe('/activities', () => { expect(body).toEqual({ id: expect.any(String), assetId: asset.id, + assetIds: null, createdAt: expect.any(String), type: 'like', comment: null, From 21515cf0f69baeace3327e095b12a410bbc513a2 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 04:07:12 +0200 Subject: [PATCH 11/14] fix: pnpm run sync:sql --- server/src/queries/activity.repository.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index 228e5cb0ba..4f460fb6be 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -65,6 +65,7 @@ select count(*) filter ( where "activity"."isLiked" = $1 + and "activity"."assetIds" is null ) as "comments", count(*) filter ( where From d9e8026e37b9c14311a9abad8f113600a6ad370b Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 04:07:33 +0200 Subject: [PATCH 12/14] fix: unit test fails --- server/test/small.factory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 04654552a3..e6b55b608a 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -242,6 +242,7 @@ const activityFactory = (activity: Partial = {}) => { userId, user: userFactory({ id: userId }), assetId: newUuid(), + assetIds: null, albumId: newUuid(), createdAt: newDate(), updatedAt: newDate(), From fa6c17f1c7040416cdd78e10578e964fe349e679 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 05:53:54 +0900 Subject: [PATCH 13/14] test: add upsertAssetIds unit tests --- server/src/services/activity.service.spec.ts | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index aea547e6db..640965969c 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -164,4 +164,46 @@ describe(ActivityService.name, () => { expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); }); + + describe('upsertAssetIds', () => { + it('should update recent activity assetIds if recent exists', async () => { + const [albumId, userId] = newUuids(); + const recent = factory.activity({ albumId, userId, assetIds: ['a', 'b'] }); + const dto = { albumId, assetIds: ['b', 'c'], type: ReactionType.ASSET }; + + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.findRecentAssetIdsActivity.mockResolvedValue(recent); + mocks.activity.updateAssetIds.mockResolvedValue(); + mocks.activity.search.mockResolvedValue([{ ...recent, assetIds: ['a', 'b', 'c'] }]); + + const result = await sut.upsertAssetIds(factory.auth({ user: { id: userId } }), dto); + + expect(mocks.activity.updateAssetIds).toHaveBeenCalledWith(recent.id, ['a', 'b', 'c']); + expect(result.duplicate).toBe(true); + expect(result.value.assetIds).toEqual(['a', 'b', 'c']); + }); + + it('should create new activity if no recent exists', async () => { + const [albumId, userId] = newUuids(); + const dto = { albumId, assetIds: ['x', 'y'], type: ReactionType.ASSET }; + const created = factory.activity({ albumId, userId, assetIds: ['x', 'y'] }); + + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); + // eslint-disable-next-line unicorn/no-useless-undefined + mocks.activity.findRecentAssetIdsActivity.mockResolvedValue(undefined); + mocks.activity.create.mockResolvedValue(created); + + const result = await sut.upsertAssetIds(factory.auth({ user: { id: userId } }), dto); + + expect(mocks.activity.create).toHaveBeenCalledWith({ + userId, + albumId, + assetIds: ['x', 'y'], + isLiked: false, + comment: undefined, + }); + expect(result.duplicate).toBe(false); + expect(result.value.assetIds).toEqual(['x', 'y']); + }); + }); }); From 62b63638d92adfe672e24384bbffe4424752860d Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 8 Oct 2025 05:53:54 +0900 Subject: [PATCH 14/14] test: add asset activity e2e tests --- e2e/src/api/specs/activity.e2e-spec.ts | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts index 1d8bb447fd..3a9f2650c2 100644 --- a/e2e/src/api/specs/activity.e2e-spec.ts +++ b/e2e/src/api/specs/activity.e2e-spec.ts @@ -5,6 +5,7 @@ import { AssetMediaResponseDto, LoginResponseDto, ReactionType, + addAssetsToAlbum, createActivity as create, createAlbum, removeAssetFromAlbum, @@ -158,6 +159,59 @@ describe('/activities', () => { expect(body.length).toBe(1); expect(body[0]).toEqual(reaction); }); + + it('asset activity: add 2 assets to album, get activity with both asset ids', async () => { + const asset1 = await utils.createAsset(admin.accessToken); + const asset2 = await utils.createAsset(admin.accessToken); + + await addAssetsToAlbum( + { + id: album.id, + bulkIdsDto: { ids: [asset1.id, asset2.id] }, + }, + { headers: asBearerAuth(admin.accessToken) }, + ); + + const { status, body } = await request(app) + .get('/activities') + .query({ albumId: album.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].type).toBe('asset'); + expect(body[0].assetIds).toEqual(expect.arrayContaining([asset1.id, asset2.id])); + }); + + it('asset activity: add 2 assets and remove 1 asset, get activity with remaining asset id', async () => { + const asset1 = await utils.createAsset(admin.accessToken); + const asset2 = await utils.createAsset(admin.accessToken); + + await addAssetsToAlbum( + { + id: album.id, + bulkIdsDto: { ids: [asset1.id, asset2.id] }, + }, + { headers: asBearerAuth(admin.accessToken) }, + ); + await removeAssetFromAlbum( + { + id: album.id, + bulkIdsDto: { + ids: [asset1.id], + }, + }, + { headers: asBearerAuth(admin.accessToken) }, + ); + + const { status, body } = await request(app) + .get('/activities') + .query({ albumId: album.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].type).toBe('asset'); + expect(body[0].assetIds).toEqual(expect.arrayContaining([asset2.id])); + }); }); describe('POST /activities', () => {