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.length).toBe(1);
expect(body[0]).toEqual(reaction); 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', () => { describe('POST /activities', () => {

@ -319,6 +319,7 @@ Class | Method | HTTP request | Description
- [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md) - [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md)
- [APIKeyResponseDto](doc//APIKeyResponseDto.md) - [APIKeyResponseDto](doc//APIKeyResponseDto.md)
- [APIKeyUpdateDto](doc//APIKeyUpdateDto.md) - [APIKeyUpdateDto](doc//APIKeyUpdateDto.md)
- [ActivityAlbumUpdateResponseDto](doc//ActivityAlbumUpdateResponseDto.md)
- [ActivityCreateDto](doc//ActivityCreateDto.md) - [ActivityCreateDto](doc//ActivityCreateDto.md)
- [ActivityResponseDto](doc//ActivityResponseDto.md) - [ActivityResponseDto](doc//ActivityResponseDto.md)
- [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.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_create_response_dto.dart';
part 'model/api_key_response_dto.dart'; part 'model/api_key_response_dto.dart';
part 'model/api_key_update_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_create_dto.dart';
part 'model/activity_response_dto.dart'; part 'model/activity_response_dto.dart';
part 'model/activity_statistics_response_dto.dart'; part 'model/activity_statistics_response_dto.dart';

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

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

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

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

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

@ -70,6 +70,9 @@ export type Activity = {
assetId: string | null; assetId: string | null;
comment: string | null; comment: string | null;
isLiked: boolean; isLiked: boolean;
aggregationId: string | null;
assetIds: string[] | null;
albumUpdateAssetCount?: number | null;
updateId: string; 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 { IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { Activity } from 'src/database'; import { Activity } from 'src/database';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto'; import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { ValidateEnum, ValidateUUID } from 'src/validation'; import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export enum ReactionType { export enum ReactionType {
COMMENT = 'comment', COMMENT = 'comment',
LIKE = 'like', LIKE = 'like',
ALBUM_UPDATE = 'album_update',
} }
export enum ReactionLevel { export enum ReactionLevel {
@ -24,6 +25,8 @@ export class ActivityResponseDto {
user!: UserResponseDto; user!: UserResponseDto;
assetId!: string | null; assetId!: string | null;
comment?: string | null; comment?: string | null;
@ApiPropertyOptional({ type: () => ActivityAlbumUpdateResponseDto, nullable: true })
albumUpdate?: ActivityAlbumUpdateResponseDto | null;
} }
export class ActivityStatisticsResponseDto { export class ActivityStatisticsResponseDto {
@ -51,6 +54,9 @@ export class ActivitySearchDto extends ActivityDto {
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true })
userId?: string; userId?: string;
@ValidateBoolean({ optional: true })
includeAlbumUpdate?: boolean;
} }
const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT; const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT;
@ -66,12 +72,34 @@ export class ActivityCreateDto extends ActivityDto {
} }
export const mapActivity = (activity: Activity): ActivityResponseDto => { export const mapActivity = (activity: Activity): ActivityResponseDto => {
const isAlbumUpdate = !!activity.aggregationId;
const assetIds = activity.assetIds ?? [];
const totalAssets = isAlbumUpdate ? (activity.albumUpdateAssetCount ?? assetIds.length) : assetIds.length;
return { return {
id: activity.id, id: isAlbumUpdate ? activity.aggregationId! : activity.id,
assetId: activity.assetId, assetId: isAlbumUpdate ? null : activity.assetId,
createdAt: activity.createdAt, createdAt: activity.createdAt,
comment: activity.comment, comment: isAlbumUpdate ? null : activity.comment,
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, type: isAlbumUpdate ? ReactionType.ALBUM_UPDATE : activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
user: mapUser(activity.user), 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 -- ActivityRepository.search
select select
"activity".*, "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 from
"activity" "activity"
inner join "user" as "user2" on "user2"."id" = "activity"."userId" inner join "user" as "user2" on "user2"."id" = "activity"."userId"
@ -24,7 +41,8 @@ from
) as "user" on true ) as "user" on true
left join "asset" on "asset"."id" = "activity"."assetId" left join "asset" on "asset"."id" = "activity"."assetId"
where where
"activity"."albumId" = $1 "activity"."albumId" = $2
and "activity"."aggregationId" is null
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
"activity"."createdAt" asc "activity"."createdAt" asc
@ -78,6 +96,7 @@ from
where where
"activity"."assetId" = $3 "activity"."assetId" = $3
and "activity"."albumId" = $4 and "activity"."albumId" = $4
and "activity"."aggregationId" is null
and ( and (
( (
"asset"."deletedAt" is null "asset"."deletedAt" is null

@ -14,6 +14,8 @@ export interface ActivitySearch {
assetId?: string | null; assetId?: string | null;
userId?: string; userId?: string;
isLiked?: boolean; isLiked?: boolean;
includeAlbumUpdates?: boolean;
albumUpdateAssetLimit?: number;
} }
@Injectable() @Injectable()
@ -22,7 +24,7 @@ export class ActivityRepository {
@GenerateSql({ params: [{ albumId: DummyValue.UUID }] }) @GenerateSql({ params: [{ albumId: DummyValue.UUID }] })
search(options: ActivitySearch) { search(options: ActivitySearch) {
const { userId, assetId, albumId, isLiked } = options; const { userId, assetId, albumId, isLiked, includeAlbumUpdates = false, albumUpdateAssetLimit = 3 } = options;
return this.db return this.db
.selectFrom('activity') .selectFrom('activity')
@ -39,12 +41,27 @@ export class ActivityRepository {
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.select((eb) => eb.fn.toJson('user').as('user')) .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') .leftJoin('asset', 'asset.id', 'activity.assetId')
.$if(!!userId, (qb) => qb.where('activity.userId', '=', userId!)) .$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(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
.$if(!!albumId, (qb) => qb.where('activity.albumId', '=', albumId!)) .$if(!!albumId, (qb) => qb.where('activity.albumId', '=', albumId!))
.$if(isLiked !== undefined, (qb) => qb.where('activity.isLiked', '=', isLiked!)) .$if(isLiked !== undefined, (qb) => qb.where('activity.isLiked', '=', isLiked!))
.$if(!includeAlbumUpdates, (qb) => qb.where('activity.aggregationId', 'is', null))
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.orderBy('activity.createdAt', 'asc') .orderBy('activity.createdAt', 'asc')
.execute(); .execute();
@ -88,6 +105,7 @@ export class ActivityRepository {
.leftJoin('asset', 'asset.id', 'activity.assetId') .leftJoin('asset', 'asset.id', 'activity.assetId')
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!)) .$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
.where('activity.albumId', '=', albumId) .where('activity.albumId', '=', albumId)
.where('activity.aggregationId', 'is', null)
.where(({ or, and, eb }) => .where(({ or, and, eb }) =>
or([ or([
and([eb('asset.deletedAt', 'is', null), eb('asset.visibility', '!=', sql.lit(AssetVisibility.Locked))]), 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))); .then((results) => new Set(results.map(({ assetId }) => assetId)));
} }
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> { async addAssetIds(albumId: string, assetIds: string[], options?: { createdBy?: string }): Promise<void> {
await this.addAssets(this.db, albumId, assetIds); 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[]) { create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
@ -269,7 +274,11 @@ export class AlbumRepository {
} }
if (assetIds.length > 0) { 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) { if (albumUsers.length > 0) {
@ -310,19 +319,19 @@ export class AlbumRepository {
} }
@Chunked({ paramIndex: 2, chunkSize: 30_000 }) @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) { if (assetIds.length === 0) {
return; return;
} }
await db await db
.insertInto('album_asset') .insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId }))) .values(assetIds.map((assetId) => ({ albumId, assetId, createdBy })))
.execute(); .execute();
} }
@Chunked({ chunkSize: 30_000 }) @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) { if (values.length === 0) {
return; return;
} }

@ -130,7 +130,7 @@ export class MemoryRepository implements IBulkAsset {
} }
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @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) { if (assetIds.length === 0) {
return; return;
} }

@ -107,7 +107,7 @@ export class TagRepository {
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 }) @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) { if (assetIds.length === 0) {
return; return;
} }

@ -132,6 +132,151 @@ export const album_delete_audit = registerFunction({
END`, 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({ export const album_asset_delete_audit = registerFunction({
name: 'album_asset_delete_audit', name: 'album_asset_delete_audit',
returnType: 'TRIGGER', returnType: 'TRIGGER',

@ -1,5 +1,6 @@
import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { import {
album_asset_generate_aggregation_id,
album_delete_audit, album_delete_audit,
album_user_after_insert, album_user_after_insert,
album_user_delete_audit, album_user_delete_audit,
@ -145,6 +146,7 @@ export class ImmichDatabase {
partner_delete_audit, partner_delete_audit,
asset_delete_audit, asset_delete_audit,
album_delete_audit, album_delete_audit,
album_asset_generate_aggregation_id,
album_user_after_insert, album_user_after_insert,
album_user_delete_audit, album_user_delete_audit,
memory_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)', where: '("isLiked" = true)',
}) })
@Check({ @Check({
name: 'activity_like_check', name: 'activity_check',
expression: `(comment IS NULL AND "isLiked" = true) OR (comment IS NOT NULL AND "isLiked" = false)`, 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({ @ForeignKeyConstraint({
columns: ['albumId', 'assetId'], columns: ['albumId', 'assetId'],
@ -61,6 +63,12 @@ export class ActivityTable {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isLiked!: Generated<boolean>; 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 }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
} }

@ -1,25 +1,53 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; 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 { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
import { import {
AfterDeleteTrigger, AfterDeleteTrigger,
AfterInsertTrigger,
Column,
CreateDateColumn, CreateDateColumn,
ForeignKeyColumn, ForeignKeyColumn,
Generated, Generated,
Table, Table,
Timestamp, Timestamp,
TriggerFunction,
UpdateDateColumn, UpdateDateColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table({ name: 'album_asset' }) @Table({ name: 'album_asset' })
@UpdatedAtTrigger('album_asset_updatedAt') @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({ @AfterDeleteTrigger({
scope: 'statement', scope: 'statement',
function: album_asset_delete_audit, function: album_asset_delete_audit,
referencingOldTableAs: 'old', referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1', 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 { export class AlbumAssetTable {
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
albumId!: string; albumId!: string;
@ -27,6 +55,12 @@ export class AlbumAssetTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
assetId!: string; 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() @CreateDateColumn()
createdAt!: Generated<Timestamp>; createdAt!: Generated<Timestamp>;

@ -1,5 +1,7 @@
import { BadRequestException } from '@nestjs/common'; 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 { ActivityService } from 'src/services/activity.service';
import { factory, newUuid, newUuids } from 'test/small.factory'; import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
@ -8,6 +10,14 @@ describe(ActivityService.name, () => {
let sut: ActivityService; let sut: ActivityService;
let mocks: ServiceMocks; let mocks: ServiceMocks;
type ActivitySearchResult = Awaited<ReturnType<ActivityRepository['search']>>[number];
const toSearchResult = (activity: Activity): ActivitySearchResult => ({
...activity,
assetIds: activity.assetIds ?? [],
albumUpdateAssetCount: activity.albumUpdateAssetCount ?? null,
});
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(ActivityService)); ({ sut, mocks } = newTestService(ActivityService));
}); });
@ -25,7 +35,17 @@ describe(ActivityService.name, () => {
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]); 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 () => { 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 }), sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
).resolves.toEqual([]); ).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 () => { 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([]); 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.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.access.activity.checkCreateAccess.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 }); 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 { export class ActivityService extends BaseService {
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> { async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [dto.albumId] }); 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({ const activities = await this.activityRepository.search({
userId: dto.userId, userId: dto.userId,
albumId: dto.albumId, albumId: dto.albumId,
assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId, assetId,
isLiked: dto.type && dto.type === ReactionType.LIKE, 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> { async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {

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

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

@ -454,7 +454,7 @@ export interface UploadFiles {
export interface IBulkAsset { export interface IBulkAsset {
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>; 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>; removeAssetIds: (id: string, assetIds: string[]) => Promise<void>;
} }

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

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

@ -62,7 +62,7 @@ describe(AssetService.name, () => {
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ 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 } }); const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });

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

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

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

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

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

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