mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
Merge 62b63638d9 into e7d6a066f8
This commit is contained in:
commit
4bf5c8fab5
20 changed files with 361 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
11
mobile/openapi/lib/model/activity_create_dto.dart
generated
11
mobile/openapi/lib/model/activity_create_dto.dart
generated
|
|
@ -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'])!,
|
||||
);
|
||||
|
|
|
|||
16
mobile/openapi/lib/model/activity_response_dto.dart
generated
16
mobile/openapi/lib/model/activity_response_dto.dart
generated
|
|
@ -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',
|
||||
|
|
|
|||
3
mobile/openapi/lib/model/reaction_type.dart
generated
3
mobile/openapi/lib/model/reaction_type.dart
generated
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export type Activity = {
|
|||
userId: string;
|
||||
user: User;
|
||||
assetId: string | null;
|
||||
assetIds: string[] | null;
|
||||
comment: string | null;
|
||||
isLiked: boolean;
|
||||
updateId: string;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ select
|
|||
count(*) filter (
|
||||
where
|
||||
"activity"."isLiked" = $1
|
||||
and "activity"."assetIds" is null
|
||||
) as "comments",
|
||||
count(*) filter (
|
||||
where
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ const activityFactory = (activity: Partial<Activity> = {}) => {
|
|||
userId,
|
||||
user: userFactory({ id: userId }),
|
||||
assetId: newUuid(),
|
||||
assetIds: null,
|
||||
albumId: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue