idubnori 2025-12-10 16:09:25 +07:00 committed by GitHub
commit d1bd48c4b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1027 additions and 112 deletions

@ -158,6 +158,76 @@ 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);
const album1 = await createAlbum(
{
createAlbumDto: {
albumName: 'Album 1',
assetIds: [asset1.id, asset2.id],
albumUsers: [{ userId: nonOwner.userId, role: AlbumUserRole.Editor }],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album1.id, includeAlbumUpdate: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body.length).toBe(1);
expect(body[0].type).toBe('album_update');
expect(body[0].albumUpdate.assetIds).toEqual(expect.arrayContaining([asset1.id, asset2.id]));
expect(body[0].albumUpdate.totalAssets).toBe(2);
// includeAlbumUpdate: false should return no activities
const { status: status2, body: body2 } = await request(app)
.get('/activities')
.query({ albumId: album1.id, includeAlbumUpdate: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status2).toBe(200);
expect(body2.length).toBe(0);
});
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);
const album1 = await createAlbum(
{
createAlbumDto: {
albumName: 'Album 1',
assetIds: [asset1.id, asset2.id],
albumUsers: [{ userId: nonOwner.userId, role: AlbumUserRole.Editor }],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await removeAssetFromAlbum(
{
id: album1.id,
bulkIdsDto: {
ids: [asset1.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album1.id, includeAlbumUpdate: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body.length).toBe(1);
expect(body[0].type).toBe('album_update');
expect(body[0].albumUpdate.assetIds).toEqual(expect.arrayContaining([asset2.id]));
expect(body[0].albumUpdate.totalAssets).toBe(1);
});
});
describe('POST /activities', () => {

@ -319,6 +319,7 @@ Class | Method | HTTP request | Description
- [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md)
- [APIKeyResponseDto](doc//APIKeyResponseDto.md)
- [APIKeyUpdateDto](doc//APIKeyUpdateDto.md)
- [ActivityAlbumUpdateResponseDto](doc//ActivityAlbumUpdateResponseDto.md)
- [ActivityCreateDto](doc//ActivityCreateDto.md)
- [ActivityResponseDto](doc//ActivityResponseDto.md)
- [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md)

@ -71,6 +71,7 @@ part 'model/api_key_create_dto.dart';
part 'model/api_key_create_response_dto.dart';
part 'model/api_key_response_dto.dart';
part 'model/api_key_update_dto.dart';
part 'model/activity_album_update_response_dto.dart';
part 'model/activity_create_dto.dart';
part 'model/activity_response_dto.dart';
part 'model/activity_statistics_response_dto.dart';

@ -133,12 +133,14 @@ class ActivitiesApi {
///
/// * [String] assetId:
///
/// * [bool] includeAlbumUpdate:
///
/// * [ReactionLevel] level:
///
/// * [ReactionType] type:
///
/// * [String] userId:
Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async {
Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, bool? includeAlbumUpdate, ReactionLevel? level, ReactionType? type, String? userId, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/activities';
@ -153,6 +155,9 @@ class ActivitiesApi {
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (includeAlbumUpdate != null) {
queryParams.addAll(_queryParams('', 'includeAlbumUpdate', includeAlbumUpdate));
}
if (level != null) {
queryParams.addAll(_queryParams('', 'level', level));
}
@ -187,13 +192,15 @@ class ActivitiesApi {
///
/// * [String] assetId:
///
/// * [bool] includeAlbumUpdate:
///
/// * [ReactionLevel] level:
///
/// * [ReactionType] type:
///
/// * [String] userId:
Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async {
final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, level: level, type: type, userId: userId, );
Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, bool? includeAlbumUpdate, ReactionLevel? level, ReactionType? type, String? userId, }) async {
final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, includeAlbumUpdate: includeAlbumUpdate, level: level, type: type, userId: userId, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

@ -190,6 +190,8 @@ class ApiClient {
return APIKeyResponseDto.fromJson(value);
case 'APIKeyUpdateDto':
return APIKeyUpdateDto.fromJson(value);
case 'ActivityAlbumUpdateResponseDto':
return ActivityAlbumUpdateResponseDto.fromJson(value);
case 'ActivityCreateDto':
return ActivityCreateDto.fromJson(value);
case 'ActivityResponseDto':

@ -0,0 +1,117 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ActivityAlbumUpdateResponseDto {
/// Returns a new [ActivityAlbumUpdateResponseDto] instance.
ActivityAlbumUpdateResponseDto({
required this.aggregationId,
this.assetIds = const [],
required this.totalAssets,
});
String aggregationId;
List<String> assetIds;
int totalAssets;
@override
bool operator ==(Object other) => identical(this, other) || other is ActivityAlbumUpdateResponseDto &&
other.aggregationId == aggregationId &&
_deepEquality.equals(other.assetIds, assetIds) &&
other.totalAssets == totalAssets;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(aggregationId.hashCode) +
(assetIds.hashCode) +
(totalAssets.hashCode);
@override
String toString() => 'ActivityAlbumUpdateResponseDto[aggregationId=$aggregationId, assetIds=$assetIds, totalAssets=$totalAssets]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'aggregationId'] = this.aggregationId;
json[r'assetIds'] = this.assetIds;
json[r'totalAssets'] = this.totalAssets;
return json;
}
/// Returns a new [ActivityAlbumUpdateResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ActivityAlbumUpdateResponseDto? fromJson(dynamic value) {
upgradeDto(value, "ActivityAlbumUpdateResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ActivityAlbumUpdateResponseDto(
aggregationId: mapValueOfType<String>(json, r'aggregationId')!,
assetIds: json[r'assetIds'] is Iterable
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
totalAssets: mapValueOfType<int>(json, r'totalAssets')!,
);
}
return null;
}
static List<ActivityAlbumUpdateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ActivityAlbumUpdateResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ActivityAlbumUpdateResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ActivityAlbumUpdateResponseDto> mapFromJson(dynamic json) {
final map = <String, ActivityAlbumUpdateResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ActivityAlbumUpdateResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ActivityAlbumUpdateResponseDto-objects as value to a dart map
static Map<String, List<ActivityAlbumUpdateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ActivityAlbumUpdateResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ActivityAlbumUpdateResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'aggregationId',
'assetIds',
'totalAssets',
};
}

@ -13,6 +13,7 @@ part of openapi.api;
class ActivityResponseDto {
/// Returns a new [ActivityResponseDto] instance.
ActivityResponseDto({
this.albumUpdate,
required this.assetId,
this.comment,
required this.createdAt,
@ -21,6 +22,8 @@ class ActivityResponseDto {
required this.user,
});
ActivityAlbumUpdateResponseDto? albumUpdate;
String? assetId;
String? comment;
@ -35,6 +38,7 @@ class ActivityResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto &&
other.albumUpdate == albumUpdate &&
other.assetId == assetId &&
other.comment == comment &&
other.createdAt == createdAt &&
@ -45,6 +49,7 @@ class ActivityResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumUpdate == null ? 0 : albumUpdate!.hashCode) +
(assetId == null ? 0 : assetId!.hashCode) +
(comment == null ? 0 : comment!.hashCode) +
(createdAt.hashCode) +
@ -53,10 +58,15 @@ class ActivityResponseDto {
(user.hashCode);
@override
String toString() => 'ActivityResponseDto[assetId=$assetId, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]';
String toString() => 'ActivityResponseDto[albumUpdate=$albumUpdate, assetId=$assetId, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.albumUpdate != null) {
json[r'albumUpdate'] = this.albumUpdate;
} else {
// json[r'albumUpdate'] = null;
}
if (this.assetId != null) {
json[r'assetId'] = this.assetId;
} else {
@ -83,6 +93,7 @@ class ActivityResponseDto {
final json = value.cast<String, dynamic>();
return ActivityResponseDto(
albumUpdate: ActivityAlbumUpdateResponseDto.fromJson(json[r'albumUpdate']),
assetId: mapValueOfType<String>(json, r'assetId'),
comment: mapValueOfType<String>(json, r'comment'),
createdAt: mapDateTime(json, r'createdAt', r'')!,

@ -25,11 +25,13 @@ class ReactionType {
static const comment = ReactionType._(r'comment');
static const like = ReactionType._(r'like');
static const albumUpdate = ReactionType._(r'album_update');
/// List of all possible values in this [enum][ReactionType].
static const values = <ReactionType>[
comment,
like,
albumUpdate,
];
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'album_update': return ReactionType.albumUpdate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

@ -24,6 +24,14 @@
"type": "string"
}
},
{
"name": "includeAlbumUpdate",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "level",
"required": false,
@ -14522,6 +14530,28 @@
},
"type": "object"
},
"ActivityAlbumUpdateResponseDto": {
"properties": {
"aggregationId": {
"type": "string"
},
"assetIds": {
"items": {
"type": "string"
},
"type": "array"
},
"totalAssets": {
"type": "integer"
}
},
"required": [
"aggregationId",
"assetIds",
"totalAssets"
],
"type": "object"
},
"ActivityCreateDto": {
"properties": {
"albumId": {
@ -14551,6 +14581,14 @@
},
"ActivityResponseDto": {
"properties": {
"albumUpdate": {
"allOf": [
{
"$ref": "#/components/schemas/ActivityAlbumUpdateResponseDto"
}
],
"nullable": true
},
"assetId": {
"nullable": true,
"type": "string"
@ -18882,7 +18920,8 @@
"ReactionType": {
"enum": [
"comment",
"like"
"like",
"album_update"
],
"type": "string"
},

@ -14,6 +14,11 @@ const oazapfts = Oazapfts.runtime(defaults);
export const servers = {
server1: "/api"
};
export type ActivityAlbumUpdateResponseDto = {
aggregationId: string;
assetIds: string[];
totalAssets: number;
};
export type UserResponseDto = {
avatarColor: UserAvatarColor;
email: string;
@ -23,6 +28,7 @@ export type UserResponseDto = {
profileImagePath: string;
};
export type ActivityResponseDto = {
albumUpdate?: (ActivityAlbumUpdateResponseDto) | null;
assetId: string | null;
comment?: string | null;
createdAt: string;
@ -1778,9 +1784,10 @@ export type WorkflowUpdateDto = {
/**
* List all activities
*/
export function getActivities({ albumId, assetId, level, $type, userId }: {
export function getActivities({ albumId, assetId, includeAlbumUpdate, level, $type, userId }: {
albumId: string;
assetId?: string;
includeAlbumUpdate?: boolean;
level?: ReactionLevel;
$type?: ReactionType;
userId?: string;
@ -1791,6 +1798,7 @@ export function getActivities({ albumId, assetId, level, $type, userId }: {
}>(`/activities${QS.query(QS.explode({
albumId,
assetId,
includeAlbumUpdate,
level,
"type": $type,
userId
@ -5124,7 +5132,8 @@ export enum ReactionLevel {
}
export enum ReactionType {
Comment = "comment",
Like = "like"
Like = "like",
AlbumUpdate = "album_update"
}
export enum UserAvatarColor {
Primary = "primary",

@ -70,6 +70,9 @@ export type Activity = {
assetId: string | null;
comment: string | null;
isLiked: boolean;
aggregationId: string | null;
assetIds: string[] | null;
albumUpdateAssetCount?: number | null;
updateId: string;
};

@ -1,12 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { Activity } from 'src/database';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { ValidateEnum, ValidateUUID } from 'src/validation';
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export enum ReactionType {
COMMENT = 'comment',
LIKE = 'like',
ALBUM_UPDATE = 'album_update',
}
export enum ReactionLevel {
@ -24,6 +25,8 @@ export class ActivityResponseDto {
user!: UserResponseDto;
assetId!: string | null;
comment?: string | null;
@ApiPropertyOptional({ type: () => ActivityAlbumUpdateResponseDto, nullable: true })
albumUpdate?: ActivityAlbumUpdateResponseDto | null;
}
export class ActivityStatisticsResponseDto {
@ -51,6 +54,9 @@ export class ActivitySearchDto extends ActivityDto {
@ValidateUUID({ optional: true })
userId?: string;
@ValidateBoolean({ optional: true })
includeAlbumUpdate?: boolean;
}
const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT;
@ -66,12 +72,34 @@ export class ActivityCreateDto extends ActivityDto {
}
export const mapActivity = (activity: Activity): ActivityResponseDto => {
const isAlbumUpdate = !!activity.aggregationId;
const assetIds = activity.assetIds ?? [];
const totalAssets = isAlbumUpdate ? (activity.albumUpdateAssetCount ?? assetIds.length) : assetIds.length;
return {
id: activity.id,
assetId: activity.assetId,
id: isAlbumUpdate ? activity.aggregationId! : activity.id,
assetId: isAlbumUpdate ? null : activity.assetId,
createdAt: activity.createdAt,
comment: activity.comment,
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
comment: isAlbumUpdate ? null : activity.comment,
type: isAlbumUpdate ? ReactionType.ALBUM_UPDATE : activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
user: mapUser(activity.user),
albumUpdate: isAlbumUpdate
? {
aggregationId: activity.aggregationId!,
assetIds,
totalAssets,
}
: undefined,
};
};
export class ActivityAlbumUpdateResponseDto {
@ApiProperty()
aggregationId!: string;
@ApiProperty({ type: [String] })
assetIds!: string[];
@ApiProperty({ type: 'integer' })
totalAssets!: number;
}

@ -3,7 +3,24 @@
-- ActivityRepository.search
select
"activity".*,
to_json("user") as "user"
to_json("user") as "user",
CASE
WHEN "activity"."aggregationId" IS NOT NULL THEN (
SELECT
COALESCE(array_agg(value), ARRAY[]::uuid[])
FROM
(
SELECT
value
FROM
unnest("activity"."assetIds") AS value
LIMIT
$1
) AS limited
)
ELSE "activity"."assetIds"
END as "assetIds",
cardinality("activity"."assetIds") as "albumUpdateAssetCount"
from
"activity"
inner join "user" as "user2" on "user2"."id" = "activity"."userId"
@ -24,7 +41,8 @@ from
) as "user" on true
left join "asset" on "asset"."id" = "activity"."assetId"
where
"activity"."albumId" = $1
"activity"."albumId" = $2
and "activity"."aggregationId" is null
and "asset"."deletedAt" is null
order by
"activity"."createdAt" asc
@ -78,6 +96,7 @@ from
where
"activity"."assetId" = $3
and "activity"."albumId" = $4
and "activity"."aggregationId" is null
and (
(
"asset"."deletedAt" is null

@ -14,6 +14,8 @@ export interface ActivitySearch {
assetId?: string | null;
userId?: string;
isLiked?: boolean;
includeAlbumUpdates?: boolean;
albumUpdateAssetLimit?: number;
}
@Injectable()
@ -22,7 +24,7 @@ export class ActivityRepository {
@GenerateSql({ params: [{ albumId: DummyValue.UUID }] })
search(options: ActivitySearch) {
const { userId, assetId, albumId, isLiked } = options;
const { userId, assetId, albumId, isLiked, includeAlbumUpdates = false, albumUpdateAssetLimit = 3 } = options;
return this.db
.selectFrom('activity')
@ -39,12 +41,27 @@ export class ActivityRepository {
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))
.select((_eb) =>
sql<string[]>`CASE
WHEN "activity"."aggregationId" IS NOT NULL THEN (
SELECT COALESCE(array_agg(value), ARRAY[]::uuid[])
FROM (
SELECT value
FROM unnest("activity"."assetIds") AS value
LIMIT ${albumUpdateAssetLimit}
) AS limited
)
ELSE "activity"."assetIds"
END`.as('assetIds'),
)
.select((_eb) => sql<number | null>`cardinality("activity"."assetIds")`.as('albumUpdateAssetCount'))
.leftJoin('asset', 'asset.id', 'activity.assetId')
.$if(!!userId, (qb) => qb.where('activity.userId', '=', userId!))
.$if(assetId === null, (qb) => qb.where('assetId', 'is', null))
.$if(assetId === null, (qb) => qb.where('activity.assetId', 'is', null))
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
.$if(!!albumId, (qb) => qb.where('activity.albumId', '=', albumId!))
.$if(isLiked !== undefined, (qb) => qb.where('activity.isLiked', '=', isLiked!))
.$if(!includeAlbumUpdates, (qb) => qb.where('activity.aggregationId', 'is', null))
.where('asset.deletedAt', 'is', null)
.orderBy('activity.createdAt', 'asc')
.execute();
@ -88,6 +105,7 @@ export class ActivityRepository {
.leftJoin('asset', 'asset.id', 'activity.assetId')
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
.where('activity.albumId', '=', albumId)
.where('activity.aggregationId', 'is', null)
.where(({ or, and, eb }) =>
or([
and([eb('asset.deletedAt', 'is', null), eb('asset.visibility', '!=', sql.lit(AssetVisibility.Locked))]),

@ -256,8 +256,13 @@ export class AlbumRepository {
.then((results) => new Set(results.map(({ assetId }) => assetId)));
}
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.addAssets(this.db, albumId, assetIds);
async addAssetIds(albumId: string, assetIds: string[], options?: { createdBy?: string }): Promise<void> {
const createdBy = options?.createdBy;
if (!createdBy) {
throw new Error('createdBy is required when adding assets to an album');
}
await this.addAssets(this.db, albumId, assetIds, createdBy);
}
create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
@ -269,7 +274,11 @@ export class AlbumRepository {
}
if (assetIds.length > 0) {
await this.addAssets(tx, newAlbum.id, assetIds);
const createdBy = album.ownerId;
if (!createdBy) {
throw new Error('ownerId is required when adding assets to a new album');
}
await this.addAssets(tx, newAlbum.id, assetIds, createdBy);
}
if (albumUsers.length > 0) {
@ -310,19 +319,19 @@ export class AlbumRepository {
}
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[], createdBy: string): Promise<void> {
if (assetIds.length === 0) {
return;
}
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.values(assetIds.map((assetId) => ({ albumId, assetId, createdBy })))
.execute();
}
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
async addAssetIdsToAlbums(values: { albumId: string; assetId: string; createdBy: string }[]): Promise<void> {
if (values.length === 0) {
return;
}

@ -130,7 +130,7 @@ export class MemoryRepository implements IBulkAsset {
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(id: string, assetIds: string[]) {
async addAssetIds(id: string, assetIds: string[], _options?: { createdBy?: string }) {
if (assetIds.length === 0) {
return;
}

@ -107,7 +107,7 @@ export class TagRepository {
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
async addAssetIds(tagId: string, assetIds: string[], _options?: { createdBy?: string }): Promise<void> {
if (assetIds.length === 0) {
return;
}

@ -132,6 +132,151 @@ export const album_delete_audit = registerFunction({
END`,
});
export const album_asset_generate_aggregation_id = registerFunction({
name: 'album_asset_generate_aggregation_id',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
DECLARE
v_now TIMESTAMP WITH TIME ZONE := clock_timestamp();
v_existing uuid;
BEGIN
IF NEW."createdAt" IS NULL THEN
NEW."createdAt" = v_now;
END IF;
SELECT "aggregationId"
INTO v_existing
FROM album_asset
WHERE "albumId" = NEW."albumId"
AND "createdBy" = NEW."createdBy"
AND "createdAt" >= v_now - INTERVAL '60 minutes'
ORDER BY "createdAt" DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
NEW."aggregationId" = v_existing;
ELSE
NEW."aggregationId" = immich_uuid_v7(v_now);
END IF;
RETURN NEW;
END`,
});
export const album_asset_sync_activity_apply = registerFunction({
name: 'album_asset_sync_activity_apply',
arguments: ['p_aggregation_id uuid', 'p_album_id uuid', 'p_user_id uuid'],
returnType: 'void',
language: 'PLPGSQL',
body: `
DECLARE
v_asset_ids uuid[];
v_created_at TIMESTAMP WITH TIME ZONE;
v_album_id uuid := p_album_id;
v_user_id uuid := p_user_id;
BEGIN
IF p_aggregation_id IS NULL OR p_album_id IS NULL OR p_user_id IS NULL THEN
RAISE NOTICE 'album_asset_sync_activity_apply called with NULL parameters: %, %, %', p_aggregation_id, p_album_id, p_user_id;
RETURN;
END IF;
SELECT
ARRAY(
SELECT aa."assetId"
FROM album_asset aa
WHERE aa."aggregationId" = p_aggregation_id
ORDER BY aa."createdAt" ASC
)::uuid[],
MIN("createdAt")
INTO v_asset_ids, v_created_at
FROM album_asset
WHERE "aggregationId" = p_aggregation_id;
IF v_asset_ids IS NULL OR array_length(v_asset_ids, 1) IS NULL THEN
DELETE FROM activity WHERE "aggregationId" = p_aggregation_id;
RETURN;
END IF;
UPDATE activity
SET
"assetIds" = v_asset_ids,
"albumId" = v_album_id,
"userId" = COALESCE(v_user_id, activity."userId"),
"createdAt" = v_created_at
WHERE "aggregationId" = p_aggregation_id;
IF NOT FOUND THEN
INSERT INTO activity (
"id",
"albumId",
"userId",
"assetId",
"comment",
"isLiked",
"aggregationId",
"assetIds",
"createdAt"
)
VALUES (
p_aggregation_id,
v_album_id,
v_user_id,
NULL,
NULL,
FALSE,
p_aggregation_id,
v_asset_ids,
v_created_at
)
ON CONFLICT ("aggregationId")
DO UPDATE
SET "assetIds" = EXCLUDED."assetIds",
"albumId" = EXCLUDED."albumId",
"userId" = COALESCE(EXCLUDED."userId", activity."userId"),
"createdAt" = EXCLUDED."createdAt";
END IF;
END`,
});
export const album_asset_sync_activity = registerFunction({
name: 'album_asset_sync_activity',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
DECLARE
v_row RECORD;
BEGIN
IF TG_OP = 'INSERT' THEN
FOR v_row IN
SELECT DISTINCT "aggregationId", "albumId", "createdBy"
FROM inserted_rows
WHERE "aggregationId" IS NOT NULL
LOOP
PERFORM album_asset_sync_activity_apply(
v_row."aggregationId",
v_row."albumId",
v_row."createdBy"
);
END LOOP;
ELSIF TG_OP = 'DELETE' THEN
FOR v_row IN
SELECT DISTINCT "aggregationId", "albumId", "createdBy"
FROM deleted_rows
WHERE "aggregationId" IS NOT NULL
LOOP
PERFORM album_asset_sync_activity_apply(
v_row."aggregationId",
v_row."albumId",
v_row."createdBy"
);
END LOOP;
END IF;
RETURN NULL;
END`,
});
export const album_asset_delete_audit = registerFunction({
name: 'album_asset_delete_audit',
returnType: 'TRIGGER',

@ -1,5 +1,6 @@
import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import {
album_asset_generate_aggregation_id,
album_delete_audit,
album_user_after_insert,
album_user_delete_audit,
@ -145,6 +146,7 @@ export class ImmichDatabase {
partner_delete_audit,
asset_delete_audit,
album_delete_audit,
album_asset_generate_aggregation_id,
album_user_after_insert,
album_user_delete_audit,
memory_delete_audit,

@ -0,0 +1,221 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_asset_generate_aggregation_id()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
DECLARE
v_now TIMESTAMP WITH TIME ZONE := clock_timestamp();
v_existing uuid;
BEGIN
IF NEW."createdAt" IS NULL THEN
NEW."createdAt" = v_now;
END IF;
SELECT "aggregationId"
INTO v_existing
FROM album_asset
WHERE "albumId" = NEW."albumId"
AND "createdBy" = NEW."createdBy"
AND "createdAt" >= v_now - INTERVAL '60 minutes'
ORDER BY "createdAt" DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
NEW."aggregationId" = v_existing;
ELSE
NEW."aggregationId" = immich_uuid_v7(v_now);
END IF;
RETURN NEW;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION album_asset_sync_activity_apply(p_aggregation_id uuid, p_album_id uuid, p_user_id uuid)
RETURNS void
LANGUAGE PLPGSQL
AS $$
DECLARE
v_asset_ids uuid[];
v_created_at TIMESTAMP WITH TIME ZONE;
v_album_id uuid := p_album_id;
v_user_id uuid := p_user_id;
BEGIN
IF p_aggregation_id IS NULL OR p_album_id IS NULL OR p_user_id IS NULL THEN
RAISE NOTICE 'album_asset_sync_activity_apply called with NULL parameters: %, %, %', p_aggregation_id, p_album_id, p_user_id;
RETURN;
END IF;
SELECT
ARRAY(
SELECT aa."assetId"
FROM album_asset aa
WHERE aa."aggregationId" = p_aggregation_id
ORDER BY aa."createdAt" ASC
)::uuid[],
MIN("createdAt")
INTO v_asset_ids, v_created_at
FROM album_asset
WHERE "aggregationId" = p_aggregation_id;
IF v_asset_ids IS NULL OR array_length(v_asset_ids, 1) IS NULL THEN
DELETE FROM activity WHERE "aggregationId" = p_aggregation_id;
RETURN;
END IF;
UPDATE activity
SET
"assetIds" = v_asset_ids,
"albumId" = v_album_id,
"userId" = COALESCE(v_user_id, activity."userId"),
"createdAt" = v_created_at
WHERE "aggregationId" = p_aggregation_id;
IF NOT FOUND THEN
INSERT INTO activity (
"id",
"albumId",
"userId",
"assetId",
"comment",
"isLiked",
"aggregationId",
"assetIds",
"createdAt"
)
VALUES (
p_aggregation_id,
v_album_id,
v_user_id,
NULL,
NULL,
FALSE,
p_aggregation_id,
v_asset_ids,
v_created_at
)
ON CONFLICT ("aggregationId")
DO UPDATE
SET "assetIds" = EXCLUDED."assetIds",
"albumId" = EXCLUDED."albumId",
"userId" = COALESCE(EXCLUDED."userId", activity."userId"),
"createdAt" = EXCLUDED."createdAt";
END IF;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION album_asset_sync_activity()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
DECLARE
v_row RECORD;
BEGIN
IF TG_OP = 'INSERT' THEN
FOR v_row IN
SELECT DISTINCT "aggregationId", "albumId", "createdBy"
FROM inserted_rows
WHERE "aggregationId" IS NOT NULL
LOOP
PERFORM album_asset_sync_activity_apply(
v_row."aggregationId",
v_row."albumId",
v_row."createdBy"
);
END LOOP;
ELSIF TG_OP = 'DELETE' THEN
FOR v_row IN
SELECT DISTINCT "aggregationId", "albumId", "createdBy"
FROM deleted_rows
WHERE "aggregationId" IS NOT NULL
LOOP
PERFORM album_asset_sync_activity_apply(
v_row."aggregationId",
v_row."albumId",
v_row."createdBy"
);
END LOOP;
END IF;
RETURN NULL;
END
$$;`.execute(db);
await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_like_check";`.execute(db);
await sql`ALTER TABLE "album_asset" ADD "createdBy" uuid;`.execute(db);
await sql`ALTER TABLE "album_asset" ADD "aggregationId" uuid;`.execute(db);
await sql`ALTER TABLE "activity" ADD "aggregationId" uuid;`.execute(db);
await sql`ALTER TABLE "activity" ADD "assetIds" uuid[];`.execute(db);
await sql`ALTER TABLE "album_asset" ADD CONSTRAINT "album_asset_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(
db,
);
await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_aggregationId_uq" UNIQUE ("aggregationId");`.execute(db);
await sql`ALTER TABLE "activity" ADD CONSTRAINT "activity_check" CHECK ((("aggregationId" IS NULL) AND ((comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false))) OR ("aggregationId" IS NOT NULL AND comment IS NULL AND "isLiked" = false));`.execute(
db,
);
await sql`CREATE INDEX "album_asset_createdBy_idx" ON "album_asset" ("createdBy");`.execute(db);
await sql`CREATE INDEX "album_asset_aggregationId_idx" ON "album_asset" ("aggregationId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_asset_sync_activity_delete"
AFTER DELETE ON "album_asset"
REFERENCING OLD TABLE AS "deleted_rows"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION album_asset_sync_activity();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_asset_sync_activity_insert"
AFTER INSERT ON "album_asset"
REFERENCING NEW TABLE AS "inserted_rows"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION album_asset_sync_activity();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_asset_generate_aggregation_id"
BEFORE INSERT ON "album_asset"
FOR EACH ROW
EXECUTE FUNCTION album_asset_generate_aggregation_id();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_asset_generate_aggregation_id', '{"type":"function","name":"album_asset_generate_aggregation_id","sql":"CREATE OR REPLACE FUNCTION album_asset_generate_aggregation_id()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n DECLARE\\n v_now TIMESTAMP WITH TIME ZONE := clock_timestamp();\\n v_existing uuid;\\n BEGIN\\n IF NEW.\\"createdAt\\" IS NULL THEN\\n NEW.\\"createdAt\\" = v_now;\\n END IF;\\n\\n SELECT \\"aggregationId\\"\\n INTO v_existing\\n FROM album_asset\\n WHERE \\"albumId\\" = NEW.\\"albumId\\"\\n AND \\"createdBy\\" = NEW.\\"createdBy\\"\\n AND \\"createdAt\\" >= v_now - INTERVAL ''60 minutes''\\n ORDER BY \\"createdAt\\" DESC\\n LIMIT 1;\\n\\n IF v_existing IS NOT NULL THEN\\n NEW.\\"aggregationId\\" = v_existing;\\n ELSE\\n NEW.\\"aggregationId\\" = immich_uuid_v7(v_now);\\n END IF;\\n\\n RETURN NEW;\\n END\\n $$;"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_asset_sync_activity_apply', '{"type":"function","name":"album_asset_sync_activity_apply","sql":"CREATE OR REPLACE FUNCTION album_asset_sync_activity_apply(p_aggregation_id uuid, p_album_id uuid, p_user_id uuid)\\n RETURNS void\\n LANGUAGE PLPGSQL\\n AS $$\\n DECLARE\\n v_asset_ids uuid[];\\n v_created_at TIMESTAMP WITH TIME ZONE;\\n v_album_id uuid := p_album_id;\\n v_user_id uuid := p_user_id;\\n BEGIN\\n IF p_aggregation_id IS NULL OR p_album_id IS NULL OR p_user_id IS NULL THEN\\n RAISE NOTICE ''album_asset_sync_activity_apply called with NULL parameters: %, %, %'', p_aggregation_id, p_album_id, p_user_id;\\n RETURN;\\n END IF;\\n\\n SELECT\\n ARRAY(\\n SELECT aa.\\"assetId\\"\\n FROM album_asset aa\\n WHERE aa.\\"aggregationId\\" = p_aggregation_id\\n ORDER BY aa.\\"createdAt\\" ASC\\n )::uuid[],\\n MIN(\\"createdAt\\")\\n INTO v_asset_ids, v_created_at\\n FROM album_asset\\n WHERE \\"aggregationId\\" = p_aggregation_id;\\n\\n IF v_asset_ids IS NULL OR array_length(v_asset_ids, 1) IS NULL THEN\\n DELETE FROM activity WHERE \\"aggregationId\\" = p_aggregation_id;\\n RETURN;\\n END IF;\\n\\n UPDATE activity\\n SET\\n \\"assetIds\\" = v_asset_ids,\\n \\"albumId\\" = v_album_id,\\n \\"userId\\" = COALESCE(v_user_id, activity.\\"userId\\"),\\n \\"createdAt\\" = v_created_at\\n WHERE \\"aggregationId\\" = p_aggregation_id;\\n\\n IF NOT FOUND THEN\\n INSERT INTO activity (\\n \\"id\\",\\n \\"albumId\\",\\n \\"userId\\",\\n \\"assetId\\",\\n \\"comment\\",\\n \\"isLiked\\",\\n \\"aggregationId\\",\\n \\"assetIds\\",\\n \\"createdAt\\"\\n )\\n VALUES (\\n p_aggregation_id,\\n v_album_id,\\n v_user_id,\\n NULL,\\n NULL,\\n FALSE,\\n p_aggregation_id,\\n v_asset_ids,\\n v_created_at\\n )\\n ON CONFLICT (\\"aggregationId\\")\\n DO UPDATE\\n SET \\"assetIds\\" = EXCLUDED.\\"assetIds\\",\\n \\"albumId\\" = EXCLUDED.\\"albumId\\",\\n \\"userId\\" = COALESCE(EXCLUDED.\\"userId\\", activity.\\"userId\\"),\\n \\"createdAt\\" = EXCLUDED.\\"createdAt\\";\\n END IF;\\n END\\n $$;"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_asset_sync_activity', '{"type":"function","name":"album_asset_sync_activity","sql":"CREATE OR REPLACE FUNCTION album_asset_sync_activity()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n DECLARE\\n v_row RECORD;\\n BEGIN\\n IF TG_OP = ''INSERT'' THEN\\n FOR v_row IN\\n SELECT DISTINCT \\"aggregationId\\", \\"albumId\\", \\"createdBy\\"\\n FROM inserted_rows\\n WHERE \\"aggregationId\\" IS NOT NULL\\n LOOP\\n PERFORM album_asset_sync_activity_apply(\\n v_row.\\"aggregationId\\",\\n v_row.\\"albumId\\",\\n v_row.\\"createdBy\\"\\n );\\n END LOOP;\\n ELSIF TG_OP = ''DELETE'' THEN\\n FOR v_row IN\\n SELECT DISTINCT \\"aggregationId\\", \\"albumId\\", \\"createdBy\\"\\n FROM deleted_rows\\n WHERE \\"aggregationId\\" IS NOT NULL\\n LOOP\\n PERFORM album_asset_sync_activity_apply(\\n v_row.\\"aggregationId\\",\\n v_row.\\"albumId\\",\\n v_row.\\"createdBy\\"\\n );\\n END LOOP;\\n END IF;\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_asset_sync_activity_delete', '{"type":"trigger","name":"album_asset_sync_activity_delete","sql":"CREATE OR REPLACE TRIGGER \\"album_asset_sync_activity_delete\\"\\n AFTER DELETE ON \\"album_asset\\"\\n REFERENCING OLD TABLE AS \\"deleted_rows\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION album_asset_sync_activity();"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_asset_sync_activity_insert', '{"type":"trigger","name":"album_asset_sync_activity_insert","sql":"CREATE OR REPLACE TRIGGER \\"album_asset_sync_activity_insert\\"\\n AFTER INSERT ON \\"album_asset\\"\\n REFERENCING NEW TABLE AS \\"inserted_rows\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION album_asset_sync_activity();"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_asset_generate_aggregation_id', '{"type":"trigger","name":"album_asset_generate_aggregation_id","sql":"CREATE OR REPLACE TRIGGER \\"album_asset_generate_aggregation_id\\"\\n BEFORE INSERT ON \\"album_asset\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION album_asset_generate_aggregation_id();"}'::jsonb);`.execute(
db,
);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "album_asset_sync_activity_delete" ON "album_asset";`.execute(db);
await sql`DROP TRIGGER "album_asset_sync_activity_insert" ON "album_asset";`.execute(db);
await sql`DROP TRIGGER "album_asset_generate_aggregation_id" ON "album_asset";`.execute(db);
await sql`DROP INDEX "album_asset_createdBy_idx";`.execute(db);
await sql`DROP INDEX "album_asset_aggregationId_idx";`.execute(db);
await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_aggregationId_uq";`.execute(db);
await sql`ALTER TABLE "activity" DROP CONSTRAINT "activity_check";`.execute(db);
await sql`ALTER TABLE "album_asset" DROP CONSTRAINT "album_asset_createdBy_fkey";`.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 "aggregationId";`.execute(db);
await sql`ALTER TABLE "activity" DROP COLUMN "assetIds";`.execute(db);
await sql`ALTER TABLE "album_asset" DROP COLUMN "createdBy";`.execute(db);
await sql`ALTER TABLE "album_asset" DROP COLUMN "aggregationId";`.execute(db);
await sql`DROP FUNCTION album_asset_generate_aggregation_id;`.execute(db);
await sql`DROP FUNCTION album_asset_sync_activity_apply;`.execute(db);
await sql`DROP FUNCTION album_asset_sync_activity;`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_asset_generate_aggregation_id';`.execute(
db,
);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_asset_sync_activity_apply';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_asset_sync_activity';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_asset_sync_activity_delete';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_asset_sync_activity_insert';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_asset_generate_aggregation_id';`.execute(
db,
);
}

@ -26,8 +26,10 @@ 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:
`(("aggregationId" IS NULL) AND ((comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false))) ` +
`OR ("aggregationId" IS NOT NULL AND comment IS NULL AND "isLiked" = false)`,
})
@ForeignKeyConstraint({
columns: ['albumId', 'assetId'],
@ -61,6 +63,12 @@ export class ActivityTable {
@Column({ type: 'boolean', default: false })
isLiked!: Generated<boolean>;
@Column({ type: 'uuid', nullable: true, unique: true })
aggregationId!: Generated<string | null>;
@Column({ type: 'uuid', array: true, nullable: true })
assetIds!: string[] | null;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

@ -1,25 +1,53 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { album_asset_delete_audit } from 'src/schema/functions';
import {
album_asset_delete_audit,
album_asset_generate_aggregation_id,
album_asset_sync_activity,
} from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
AfterInsertTrigger,
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Table,
Timestamp,
TriggerFunction,
UpdateDateColumn,
} from 'src/sql-tools';
@Table({ name: 'album_asset' })
@UpdatedAtTrigger('album_asset_updatedAt')
@TriggerFunction({
timing: 'before',
actions: ['insert'],
scope: 'row',
function: album_asset_generate_aggregation_id,
})
@AfterInsertTrigger({
scope: 'statement',
name: 'album_asset_sync_activity_insert',
function: album_asset_sync_activity,
referencingNewTableAs: 'inserted_rows',
when: 'pg_trigger_depth() <= 1',
})
@AfterDeleteTrigger({
scope: 'statement',
function: album_asset_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
@AfterDeleteTrigger({
scope: 'statement',
name: 'album_asset_sync_activity_delete',
function: album_asset_sync_activity,
referencingOldTableAs: 'deleted_rows',
when: 'pg_trigger_depth() <= 1',
})
export class AlbumAssetTable {
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
albumId!: string;
@ -27,6 +55,12 @@ export class AlbumAssetTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
assetId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
createdBy!: string | null;
@Column({ type: 'uuid', nullable: true, index: true })
aggregationId!: Generated<string> | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;

@ -1,5 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto';
import type { Activity } from 'src/database';
import { ReactionLevel, ReactionType } from 'src/dtos/activity.dto';
import type { ActivityRepository } from 'src/repositories/activity.repository';
import { ActivityService } from 'src/services/activity.service';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -8,6 +10,14 @@ describe(ActivityService.name, () => {
let sut: ActivityService;
let mocks: ServiceMocks;
type ActivitySearchResult = Awaited<ReturnType<ActivityRepository['search']>>[number];
const toSearchResult = (activity: Activity): ActivitySearchResult => ({
...activity,
assetIds: activity.assetIds ?? [],
albumUpdateAssetCount: activity.albumUpdateAssetCount ?? null,
});
beforeEach(() => {
({ sut, mocks } = newTestService(ActivityService));
});
@ -25,7 +35,17 @@ describe(ActivityService.name, () => {
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
expect(mocks.activity.search).toHaveBeenCalledTimes(1);
expect(mocks.activity.search).toHaveBeenCalledWith(
expect.objectContaining({
assetId,
albumId,
isLiked: undefined,
userId: undefined,
includeAlbumUpdates: false,
albumUpdateAssetLimit: 3,
}),
);
});
it('should filter by type=like', async () => {
@ -38,7 +58,17 @@ describe(ActivityService.name, () => {
sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
expect(mocks.activity.search).toHaveBeenCalledTimes(1);
expect(mocks.activity.search).toHaveBeenCalledWith(
expect.objectContaining({
assetId,
albumId,
isLiked: true,
userId: undefined,
includeAlbumUpdates: false,
albumUpdateAssetLimit: 3,
}),
);
});
it('should filter by type=comment', async () => {
@ -49,7 +79,110 @@ describe(ActivityService.name, () => {
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
expect(mocks.activity.search).toHaveBeenCalledTimes(1);
expect(mocks.activity.search).toHaveBeenCalledWith(
expect.objectContaining({
assetId,
albumId,
isLiked: false,
userId: undefined,
includeAlbumUpdates: false,
albumUpdateAssetLimit: 3,
}),
);
});
it('should return album updates when type=album_update', async () => {
const [albumId, aggregationId, assetId] = newUuids();
const createdAt = new Date('2024-01-01T00:00:00.000Z');
const user = factory.user();
const activity = factory.activity({
id: aggregationId,
aggregationId,
assetIds: [assetId],
albumId,
assetId: null,
comment: null,
createdAt,
user,
userId: user.id,
});
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([toSearchResult(activity)]);
const result = await sut.getAll(factory.auth(), {
albumId,
type: ReactionType.ALBUM_UPDATE,
includeAlbumUpdate: true,
});
expect(result).toEqual([
expect.objectContaining({
id: aggregationId,
type: ReactionType.ALBUM_UPDATE,
albumUpdate: {
aggregationId,
totalAssets: 1,
assetIds: [assetId],
},
}),
]);
expect(result[0].user.id).toBe(user.id);
expect(mocks.activity.search).toHaveBeenCalledTimes(1);
expect(mocks.activity.search).toHaveBeenCalledWith(
expect.objectContaining({
albumId,
assetId: undefined,
includeAlbumUpdates: true,
albumUpdateAssetLimit: 3,
userId: undefined,
}),
);
});
it('should include album updates for album level requests', async () => {
const [albumId, activityAssetId, aggregationId] = newUuids();
const createdAt = new Date('2024-04-01T00:00:00.000Z');
const user = factory.user();
const activity = factory.activity({ albumId, assetId: activityAssetId, createdAt: new Date('2024-03-01') });
const albumUpdate = factory.activity({
id: aggregationId,
aggregationId,
assetIds: ['asset-1', 'asset-2'],
albumId,
assetId: null,
comment: null,
createdAt,
user,
userId: user.id,
});
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([toSearchResult(activity), toSearchResult(albumUpdate)]);
const result = await sut.getAll(factory.auth(), {
albumId,
level: ReactionLevel.ALBUM,
includeAlbumUpdate: true,
});
expect(result).toHaveLength(2);
expect(result[0].createdAt <= result[1].createdAt).toBeTruthy();
expect(result.find((item) => item.type === ReactionType.ALBUM_UPDATE)?.albumUpdate).toBeDefined();
expect(mocks.activity.search).toHaveBeenCalledTimes(1);
expect(mocks.activity.search).toHaveBeenCalledWith(
expect.objectContaining({
assetId: null,
albumId,
isLiked: undefined,
userId: undefined,
includeAlbumUpdates: true,
albumUpdateAssetLimit: 3,
}),
);
});
});
@ -127,7 +260,7 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([activity]);
mocks.activity.search.mockResolvedValue([toSearchResult(activity)]);
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });

@ -19,14 +19,27 @@ import { BaseService } from 'src/services/base.service';
export class ActivityService extends BaseService {
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [dto.albumId] });
const assetId = dto.level === ReactionLevel.ALBUM ? null : dto.assetId;
const includeAlbumUpdates = dto.includeAlbumUpdate === true && dto.level !== ReactionLevel.ASSET;
const isLiked = dto.type === ReactionType.LIKE ? true : dto.type === ReactionType.COMMENT ? false : undefined;
const activities = await this.activityRepository.search({
userId: dto.userId,
albumId: dto.albumId,
assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId,
isLiked: dto.type && dto.type === ReactionType.LIKE,
assetId,
isLiked,
includeAlbumUpdates,
albumUpdateAssetLimit: 3, // NOTE: currently, fixed limit
});
return activities.map((activity) => mapActivity(activity));
const mapped = activities.map((activity) => mapActivity(activity));
if (dto.type === ReactionType.ALBUM_UPDATE) {
return mapped.filter((activity) => activity.type === ReactionType.ALBUM_UPDATE);
}
return mapped;
}
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {

@ -621,7 +621,9 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3'], {
createdBy: authStub.admin.user.id,
});
});
it('should not set the thumbnail if the album has one already', async () => {
@ -661,7 +663,9 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3'], {
createdBy: authStub.user1.user.id,
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123',
recipientId: 'admin_id',
@ -698,7 +702,9 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3'], {
createdBy: authStub.adminSharedLink.user.id,
});
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id,
@ -804,12 +810,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1', createdBy: authStub.admin.user.id },
{ albumId: 'album-123', assetId: 'asset-2', createdBy: authStub.admin.user.id },
{ albumId: 'album-123', assetId: 'asset-3', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-1', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-2', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-3', createdBy: authStub.admin.user.id },
]);
});
@ -840,12 +846,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-id',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1', createdBy: authStub.admin.user.id },
{ albumId: 'album-123', assetId: 'asset-2', createdBy: authStub.admin.user.id },
{ albumId: 'album-123', assetId: 'asset-3', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-1', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-2', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-3', createdBy: authStub.admin.user.id },
]);
});
@ -876,12 +882,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1', createdBy: authStub.user1.user.id },
{ albumId: 'album-123', assetId: 'asset-2', createdBy: authStub.user1.user.id },
{ albumId: 'album-123', assetId: 'asset-3', createdBy: authStub.user1.user.id },
{ albumId: 'album-321', assetId: 'asset-1', createdBy: authStub.user1.user.id },
{ albumId: 'album-321', assetId: 'asset-2', createdBy: authStub.user1.user.id },
{ albumId: 'album-321', assetId: 'asset-3', createdBy: authStub.user1.user.id },
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123',
@ -936,9 +942,9 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1', createdBy: authStub.adminSharedLink.user.id },
{ albumId: 'album-123', assetId: 'asset-2', createdBy: authStub.adminSharedLink.user.id },
{ albumId: 'album-123', assetId: 'asset-3', createdBy: authStub.adminSharedLink.user.id },
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123',
@ -977,12 +983,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1', createdBy: authStub.admin.user.id },
{ albumId: 'album-123', assetId: 'asset-2', createdBy: authStub.admin.user.id },
{ albumId: 'album-123', assetId: 'asset-3', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-1', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-2', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-3', createdBy: authStub.admin.user.id },
]);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
@ -1014,9 +1020,9 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-2', createdBy: authStub.admin.user.id },
{ albumId: 'album-321', assetId: 'asset-3', createdBy: authStub.admin.user.id },
]);
});

@ -171,6 +171,7 @@ export class AlbumService extends BaseService {
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids },
{ createdBy: auth.user.id },
);
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
@ -215,7 +216,7 @@ export class AlbumService extends BaseService {
return results;
}
const albumAssetValues: { albumId: string; assetId: string }[] = [];
const albumAssetValues: { albumId: string; assetId: string; createdBy: string }[] = [];
const events: { id: string; recipients: string[] }[] = [];
for (const albumId of allowedAlbumIds) {
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
@ -228,7 +229,7 @@ export class AlbumService extends BaseService {
results.success = true;
for (const assetId of notPresentAssetIds) {
albumAssetValues.push({ albumId, assetId });
albumAssetValues.push({ albumId, assetId, createdBy: auth.user.id });
}
await this.albumRepository.update(albumId, {
id: albumId,

@ -454,7 +454,7 @@ export interface UploadFiles {
export interface IBulkAsset {
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
addAssetIds: (id: string, assetIds: string[]) => Promise<void>;
addAssetIds: (id: string, assetIds: string[], options?: { createdBy?: string }) => Promise<void>;
removeAssetIds: (id: string, assetIds: string[]) => Promise<void>;
}

@ -28,6 +28,7 @@ export const addAssets = async (
auth: AuthDto,
repositories: { access: AccessRepository; bulk: IBulkAsset },
dto: { parentId: string; assetIds: string[] },
options?: { createdBy?: string },
) => {
const { access, bulk } = repositories;
const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds);
@ -58,7 +59,9 @@ export const addAssets = async (
const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
if (newAssetIds.length > 0) {
await bulk.addAssetIds(dto.parentId, newAssetIds);
await (options?.createdBy
? bulk.addAssetIds(dto.parentId, newAssetIds, { createdBy: options.createdBy })
: bulk.addAssetIds(dto.parentId, newAssetIds));
}
return results;

@ -212,8 +212,10 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { album, result };
}
async newAlbumAsset(albumAsset: { albumId: string; assetId: string }) {
const result = await this.get(AlbumRepository).addAssetIds(albumAsset.albumId, [albumAsset.assetId]);
async newAlbumAsset(albumAsset: { albumId: string; assetId: string; createdBy: string }) {
const result = await this.get(AlbumRepository).addAssetIds(albumAsset.albumId, [albumAsset.assetId], {
createdBy: albumAsset.createdBy,
});
return { albumAsset, result };
}

@ -62,7 +62,7 @@ describe(AssetService.name, () => {
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: oldAsset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: oldAsset.id, createdBy: user.id });
const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });

@ -41,7 +41,7 @@ describe(SharedLinkService.name, () => {
for (const date of dates) {
const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user.id });
}
const sharedLinkRepo = ctx.get(SharedLinkRepository);

@ -37,7 +37,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
@ -86,7 +86,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: auth.user.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
@ -106,7 +106,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
const { asset } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.Editor });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
@ -125,16 +125,16 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset1User2.id, make: 'asset1User2' });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id, createdBy: user2.id });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset2User2.id, make: 'asset2User2' });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset2User2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset2User2.id, createdBy: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id, createdBy: user2.id });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id, createdBy: user2.id });
await ctx.newExif({ assetId: asset3User2.id, make: 'asset3User2' });
await wait(2);
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
@ -202,9 +202,9 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
await ctx.newExif({ assetId: album1Asset.id, make: 'album1Asset' });
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id, createdBy: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
@ -240,7 +240,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// ack initial album asset sync
await ctx.syncAckAll(auth, response);
await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id, createdBy: user2.id });
await wait(2);
// should backfill the new asset even though it's older than the first asset
@ -268,7 +268,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
await ctx.newExif({ assetId: asset.id, make: 'asset' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
@ -315,11 +315,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
const { album } = await ctx.newAlbum({ ownerId: user2.id });
const { asset: newerAsset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: newerAsset.id, make: 'newerAsset' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: assetWithExif.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: assetWithExif.id, createdBy: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: assetDelayedExif.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: assetDelayedExif.id, createdBy: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: newerAsset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: newerAsset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);

@ -54,7 +54,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
libraryId: null,
});
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
@ -93,7 +93,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: auth.user.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
@ -112,7 +112,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
const { user: user3 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user3.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user3.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.Editor });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
@ -130,15 +130,15 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id, createdBy: user2.id });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset2User2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset2User2.id, createdBy: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id, createdBy: user2.id });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id, createdBy: user2.id });
await wait(2);
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
@ -201,9 +201,9 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'album1Asset' });
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id, createdBy: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
@ -239,7 +239,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
// ack initial album asset sync
await ctx.syncAckAll(auth, response);
await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id, createdBy: user2.id });
await wait(2);
// should backfill the new asset even though it's older than the first asset
@ -266,7 +266,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: false });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);

@ -24,7 +24,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
@ -48,7 +48,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toEqual([
@ -72,7 +72,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: auth.user.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
@ -97,7 +97,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: user2.id });
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
@ -108,10 +108,10 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const { asset: album2Asset } = await ctx.newAsset({ ownerId: auth.user.id });
// Backfill album
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: album2Asset.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: album2Asset.id, createdBy: user2.id });
await wait(2);
const { album: album1 } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id, createdBy: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toEqual([
@ -160,7 +160,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const albumRepo = ctx.get(AlbumRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toEqual([
@ -201,7 +201,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toEqual([
@ -242,7 +242,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
const albumRepo = ctx.get(AlbumRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id, createdBy: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toEqual([

@ -254,19 +254,28 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
const activityFactory = (activity: Partial<Activity> = {}) => {
const userId = activity.userId || newUuid();
return {
const value = {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: userFactory({ id: userId }),
assetId: newUuid(),
aggregationId: null,
assetIds: [] as string[],
albumUpdateAssetCount: 0,
albumId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
...activity,
};
if ((value.albumUpdateAssetCount == null || value.albumUpdateAssetCount === 0) && Array.isArray(value.assetIds)) {
value.albumUpdateAssetCount = value.assetIds.length;
}
return value;
};
const apiKeyFactory = (apiKey: Partial<ApiKey> = {}) => ({

@ -73,6 +73,7 @@
const deleteMessages: Record<ReactionType, string> = {
[ReactionType.Comment]: $t('comment_deleted'),
[ReactionType.Like]: $t('like_deleted'),
[ReactionType.AlbumUpdate]: '', // NOTE: No delete message for AlbumUpdate
};
toastManager.success(deleteMessages[reaction.type]);
} catch (error) {