This commit is contained in:
idubnori 2025-10-17 18:27:40 +02:00 committed by GitHub
commit 4bf5c8fab5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 361 additions and 10 deletions

View file

@ -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', () => {
@ -174,6 +228,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 +245,7 @@ describe('/activities', () => {
expect(body).toEqual({
id: expect.any(String),
assetId: null,
assetIds: null,
createdAt: expect.any(String),
type: 'like',
comment: null,
@ -235,6 +291,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 +308,7 @@ describe('/activities', () => {
expect(body).toEqual({
id: expect.any(String),
assetId: asset.id,
assetIds: null,
createdAt: expect.any(String),
type: 'like',
comment: null,

View file

@ -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<String> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'albumId')!,
assetId: mapValueOfType<String>(json, r'assetId'),
assetIds: json[r'assetIds'] is Iterable
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
comment: mapValueOfType<String>(json, r'comment'),
type: ReactionType.fromJson(json[r'type'])!,
);

View file

@ -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<String>? 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'assetId'),
assetIds: json[r'assetIds'] is Iterable
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
comment: mapValueOfType<String>(json, r'comment'),
createdAt: mapDateTime(json, r'createdAt', r'')!,
id: mapValueOfType<String>(json, r'id')!,
@ -137,6 +150,7 @@ class ActivityResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'assetIds',
'createdAt',
'id',
'type',

View file

@ -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 = <ReactionType>[
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');

View file

@ -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",
@ -13710,7 +13724,8 @@
"ReactionType": {
"enum": [
"comment",
"like"
"like",
"asset"
],
"type": "string"
},

View file

@ -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;
};
@ -4632,7 +4634,8 @@ export enum ReactionLevel {
}
export enum ReactionType {
Comment = "comment",
Like = "like"
Like = "like",
Asset = "asset"
}
export enum UserAvatarColor {
Primary = "primary",

View file

@ -63,6 +63,7 @@ export type Activity = {
userId: string;
user: User;
assetId: string | null;
assetIds: string[] | null;
comment: string | null;
isLiked: boolean;
updateId: string;

View file

@ -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),
};
};

View file

@ -65,6 +65,7 @@ select
count(*) filter (
where
"activity"."isLiked" = $1
and "activity"."assetIds" is null
) as "comments",
count(*) filter (
where

View file

@ -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();
@ -81,7 +100,11 @@ export class ActivityRepository {
const result = await this.db
.selectFrom('activity')
.select((eb) => [
eb.fn.countAll<number>().filterWhere('activity.isLiked', '=', false).as('comments'),
eb.fn
.countAll<number>()
.filterWhere('activity.isLiked', '=', false)
.filterWhere('activity.assetIds', 'is', null)
.as('comments'),
eb.fn.countAll<number>().filterWhere('activity.isLiked', '=', true).as('likes'),
])
.innerJoin('user', (join) => join.onRef('user.id', '=', 'activity.userId').on('user.deletedAt', 'is', null))

View file

@ -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 }];

View file

@ -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',

View file

@ -0,0 +1,52 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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`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 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<any>): Promise<void> {
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,
);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_activity_assetids_update_on_albumasset_delete';`.execute(
db,
);
}

View file

@ -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,9 @@ 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 })
comment!: string | null;

View file

@ -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;

View file

@ -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']);
});
});
});

View file

@ -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<ActivityStatisticsResponseDto> {
@ -40,6 +41,7 @@ export class ActivityService extends BaseService {
const common = {
userId: auth.user.id,
assetId: dto.assetId,
assetIds: dto.assetIds,
albumId: dto.albumId,
};
@ -68,8 +70,58 @@ export class ActivityService extends BaseService {
return { duplicate, value: mapActivity(activity) };
}
async upsertAssetIds(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
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 = [...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<void> {
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.debug(
`AlbumAssets event received: albumId=${payload.id}, userId=${payload.userId}, assetIds=${payload.assetIds.join(', ')}`,
);
await this.upsertAssetIds({ user: { id: payload.userId } } as AuthDto, {
assetIds: payload.assetIds,
albumId: payload.id,
type: ReactionType.ASSET,
});
}
}

View file

@ -188,6 +188,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;
@ -227,6 +230,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 });
}

View file

@ -242,6 +242,7 @@ const activityFactory = (activity: Partial<Activity> = {}) => {
userId,
user: userFactory({ id: userId }),
assetId: newUuid(),
assetIds: null,
albumId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),

View file

@ -74,6 +74,7 @@
const deleteMessages: Record<ReactionType, string> = {
[ReactionType.Comment]: $t('comment_deleted'),
[ReactionType.Like]: $t('like_deleted'),
[ReactionType.Asset]: '', // can not delete asset activity
};
notificationController.show({
message: deleteMessages[reaction.type],
@ -239,6 +240,41 @@
</div>
{/if}
</div>
{:else if reaction.type === ReactionType.Asset}
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
<div class="flex items-center">
<UserAvatar user={reaction.user} size="sm" />
</div>
<div class="w-full leading-4 overflow-hidden self-center break-words text-sm">
{$t('asset_added_to_album')}
</div>
{#if assetId === undefined && reaction.assetIds && reaction.assetIds.length > 0}
<div class="relative aspect-square w-[75px] h-[75px]">
<a href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetIds[0]}`)}>
<img
class="rounded-lg w-[75px] h-[75px] object-cover"
src={getAssetThumbnailUrl(reaction.assetIds[0])}
alt="Profile picture of {reaction.user.name}, who added this asset"
/>
{#if reaction.assetIds.length > 1}
<span
class="absolute bottom-1 right-1 bg-black bg-opacity-60 text-white text-xs rounded px-2 py-0.5 pointer-events-none select-none"
>
+{reaction.assetIds.length - 1}
</span>
{/if}
</a>
</div>
{/if}
</div>
{#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
>
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
</div>
{/if}
{/if}
{/each}
</div>