diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1beaec0ae6..3ba9327ec4 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -481,6 +481,8 @@ Class | Method | HTTP request | Description - [SyncMemoryV1](doc//SyncMemoryV1.md) - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) - [SyncPartnerV1](doc//SyncPartnerV1.md) + - [SyncPersonDeleteV1](doc//SyncPersonDeleteV1.md) + - [SyncPersonV1](doc//SyncPersonV1.md) - [SyncRequestType](doc//SyncRequestType.md) - [SyncStackDeleteV1](doc//SyncStackDeleteV1.md) - [SyncStackV1](doc//SyncStackV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 3998961720..e771664827 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -264,6 +264,8 @@ part 'model/sync_memory_delete_v1.dart'; part 'model/sync_memory_v1.dart'; part 'model/sync_partner_delete_v1.dart'; part 'model/sync_partner_v1.dart'; +part 'model/sync_person_delete_v1.dart'; +part 'model/sync_person_v1.dart'; part 'model/sync_request_type.dart'; part 'model/sync_stack_delete_v1.dart'; part 'model/sync_stack_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0edc2638bc..8bdda3a320 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -584,6 +584,10 @@ class ApiClient { return SyncPartnerDeleteV1.fromJson(value); case 'SyncPartnerV1': return SyncPartnerV1.fromJson(value); + case 'SyncPersonDeleteV1': + return SyncPersonDeleteV1.fromJson(value); + case 'SyncPersonV1': + return SyncPersonV1.fromJson(value); case 'SyncRequestType': return SyncRequestTypeTypeTransformer().decode(value); case 'SyncStackDeleteV1': diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index ecaadc9c31..60cc4da6cb 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -56,6 +56,8 @@ class SyncEntityType { static const memoryToAssetDeleteV1 = SyncEntityType._(r'MemoryToAssetDeleteV1'); static const stackV1 = SyncEntityType._(r'StackV1'); static const stackDeleteV1 = SyncEntityType._(r'StackDeleteV1'); + static const personV1 = SyncEntityType._(r'PersonV1'); + static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); /// List of all possible values in this [enum][SyncEntityType]. @@ -93,6 +95,8 @@ class SyncEntityType { memoryToAssetDeleteV1, stackV1, stackDeleteV1, + personV1, + personDeleteV1, syncAckV1, ]; @@ -165,6 +169,8 @@ class SyncEntityTypeTypeTransformer { case r'MemoryToAssetDeleteV1': return SyncEntityType.memoryToAssetDeleteV1; case r'StackV1': return SyncEntityType.stackV1; case r'StackDeleteV1': return SyncEntityType.stackDeleteV1; + case r'PersonV1': return SyncEntityType.personV1; + case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; case r'SyncAckV1': return SyncEntityType.syncAckV1; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/sync_person_delete_v1.dart b/mobile/openapi/lib/model/sync_person_delete_v1.dart new file mode 100644 index 0000000000..002f5c5b83 --- /dev/null +++ b/mobile/openapi/lib/model/sync_person_delete_v1.dart @@ -0,0 +1,99 @@ +// +// 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 SyncPersonDeleteV1 { + /// Returns a new [SyncPersonDeleteV1] instance. + SyncPersonDeleteV1({ + required this.personId, + }); + + String personId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPersonDeleteV1 && + other.personId == personId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (personId.hashCode); + + @override + String toString() => 'SyncPersonDeleteV1[personId=$personId]'; + + Map toJson() { + final json = {}; + json[r'personId'] = this.personId; + return json; + } + + /// Returns a new [SyncPersonDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPersonDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPersonDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPersonDeleteV1( + personId: mapValueOfType(json, r'personId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPersonDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPersonDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPersonDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPersonDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'personId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_person_v1.dart b/mobile/openapi/lib/model/sync_person_v1.dart new file mode 100644 index 0000000000..e86c22f64b --- /dev/null +++ b/mobile/openapi/lib/model/sync_person_v1.dart @@ -0,0 +1,191 @@ +// +// 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 SyncPersonV1 { + /// Returns a new [SyncPersonV1] instance. + SyncPersonV1({ + required this.birthDate, + required this.color, + required this.createdAt, + required this.faceAssetId, + required this.id, + required this.isFavorite, + required this.isHidden, + required this.name, + required this.ownerId, + required this.thumbnailPath, + required this.updatedAt, + }); + + DateTime? birthDate; + + String? color; + + DateTime createdAt; + + String? faceAssetId; + + String id; + + bool isFavorite; + + bool isHidden; + + String name; + + String ownerId; + + String thumbnailPath; + + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPersonV1 && + other.birthDate == birthDate && + other.color == color && + other.createdAt == createdAt && + other.faceAssetId == faceAssetId && + other.id == id && + other.isFavorite == isFavorite && + other.isHidden == isHidden && + other.name == name && + other.ownerId == ownerId && + other.thumbnailPath == thumbnailPath && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + + (createdAt.hashCode) + + (faceAssetId == null ? 0 : faceAssetId!.hashCode) + + (id.hashCode) + + (isFavorite.hashCode) + + (isHidden.hashCode) + + (name.hashCode) + + (ownerId.hashCode) + + (thumbnailPath.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SyncPersonV1[birthDate=$birthDate, color=$color, createdAt=$createdAt, faceAssetId=$faceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, ownerId=$ownerId, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + if (this.birthDate != null) { + json[r'birthDate'] = this.birthDate!.toUtc().toIso8601String(); + } else { + // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.faceAssetId != null) { + json[r'faceAssetId'] = this.faceAssetId; + } else { + // json[r'faceAssetId'] = null; + } + json[r'id'] = this.id; + json[r'isFavorite'] = this.isFavorite; + json[r'isHidden'] = this.isHidden; + json[r'name'] = this.name; + json[r'ownerId'] = this.ownerId; + json[r'thumbnailPath'] = this.thumbnailPath; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [SyncPersonV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPersonV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPersonV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPersonV1( + birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), + createdAt: mapDateTime(json, r'createdAt', r'')!, + faceAssetId: mapValueOfType(json, r'faceAssetId'), + id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + isHidden: mapValueOfType(json, r'isHidden')!, + name: mapValueOfType(json, r'name')!, + ownerId: mapValueOfType(json, r'ownerId')!, + thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPersonV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPersonV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPersonV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPersonV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'birthDate', + 'color', + 'createdAt', + 'faceAssetId', + 'id', + 'isFavorite', + 'isHidden', + 'name', + 'ownerId', + 'thumbnailPath', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c13d791d84..0b121d96c6 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -38,6 +38,7 @@ class SyncRequestType { static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1'); static const stacksV1 = SyncRequestType._(r'StacksV1'); static const usersV1 = SyncRequestType._(r'UsersV1'); + static const peopleV1 = SyncRequestType._(r'PeopleV1'); /// List of all possible values in this [enum][SyncRequestType]. static const values = [ @@ -56,6 +57,7 @@ class SyncRequestType { partnerStacksV1, stacksV1, usersV1, + peopleV1, ]; static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); @@ -109,6 +111,7 @@ class SyncRequestTypeTypeTransformer { case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1; case r'StacksV1': return SyncRequestType.stacksV1; case r'UsersV1': return SyncRequestType.usersV1; + case r'PeopleV1': return SyncRequestType.peopleV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1624d0ed7e..fbd7165dd5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13834,6 +13834,8 @@ "MemoryToAssetDeleteV1", "StackV1", "StackDeleteV1", + "PersonV1", + "PersonDeleteV1", "SyncAckV1" ], "type": "string" @@ -13983,6 +13985,74 @@ ], "type": "object" }, + "SyncPersonDeleteV1": { + "properties": { + "personId": { + "type": "string" + } + }, + "required": [ + "personId" + ], + "type": "object" + }, + "SyncPersonV1": { + "properties": { + "birthDate": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "color": { + "nullable": true, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "faceAssetId": { + "nullable": true, + "type": "string" + }, + "id": { + "type": "string" + }, + "isFavorite": { + "type": "boolean" + }, + "isHidden": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "thumbnailPath": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "birthDate", + "color", + "createdAt", + "faceAssetId", + "id", + "isFavorite", + "isHidden", + "name", + "ownerId", + "thumbnailPath", + "updatedAt" + ], + "type": "object" + }, "SyncRequestType": { "enum": [ "AlbumsV1", @@ -13999,7 +14069,8 @@ "PartnerAssetExifsV1", "PartnerStacksV1", "StacksV1", - "UsersV1" + "UsersV1", + "PeopleV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 55991b8599..c869510b6f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4095,6 +4095,8 @@ export enum SyncEntityType { MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1", StackV1 = "StackV1", StackDeleteV1 = "StackDeleteV1", + PersonV1 = "PersonV1", + PersonDeleteV1 = "PersonDeleteV1", SyncAckV1 = "SyncAckV1" } export enum SyncRequestType { @@ -4112,7 +4114,8 @@ export enum SyncRequestType { PartnerAssetExifsV1 = "PartnerAssetExifsV1", PartnerStacksV1 = "PartnerStacksV1", StacksV1 = "StacksV1", - UsersV1 = "UsersV1" + UsersV1 = "UsersV1", + PeopleV1 = "PeopleV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index db1fd29418..53b0041462 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -233,6 +233,26 @@ export class SyncStackDeleteV1 { stackId!: string; } +@ExtraModel() +export class SyncPersonV1 { + id!: string; + createdAt!: Date; + updatedAt!: Date; + ownerId!: string; + name!: string; + birthDate!: Date | null; + thumbnailPath!: string; + isHidden!: boolean; + isFavorite!: boolean; + color!: string | null; + faceAssetId!: string | null; +} + +@ExtraModel() +export class SyncPersonDeleteV1 { + personId!: string; +} + @ExtraModel() export class SyncAckV1 {} @@ -270,6 +290,8 @@ export type SyncItem = { [SyncEntityType.PartnerStackBackfillV1]: SyncStackV1; [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackV1]: SyncStackV1; + [SyncEntityType.PersonV1]: SyncPersonV1; + [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.SyncAckV1]: SyncAckV1; }; diff --git a/server/src/enum.ts b/server/src/enum.ts index d211420ab5..81af35cf0c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -588,6 +588,7 @@ export enum SyncRequestType { PartnerStacksV1 = 'PartnerStacksV1', StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', + PeopleV1 = 'PeopleV1', } export enum SyncEntityType { @@ -635,6 +636,9 @@ export enum SyncEntityType { StackV1 = 'StackV1', StackDeleteV1 = 'StackDeleteV1', + PersonV1 = 'PersonV1', + PersonDeleteV1 = 'PersonDeleteV1', + SyncAckV1 = 'SyncAckV1', } diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 50a89655cb..f68be8c83f 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -749,6 +749,40 @@ where order by "updateId" asc +-- SyncRepository.people.getDeletes +select + "id", + "personId" +from + "person_audit" +where + "ownerId" = $1 + and "deletedAt" < now() - interval '1 millisecond' +order by + "id" asc + +-- SyncRepository.people.getUpserts +select + "id", + "createdAt", + "updatedAt", + "ownerId", + "name", + "birthDate", + "thumbnailPath", + "isHidden", + "isFavorite", + "color", + "updateId", + "faceAssetId" +from + "person" +where + "ownerId" = $1 + and "updatedAt" < now() - interval '1 millisecond' +order by + "updateId" asc + -- SyncRepository.stack.getDeletes select "id", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 699aaec83d..3bc09c97eb 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -15,7 +15,8 @@ type AuditTables = | 'album_assets_audit' | 'memories_audit' | 'memory_assets_audit' - | 'stacks_audit'; + | 'stacks_audit' + | 'person_audit'; type UpsertTables = | 'users' | 'partners' @@ -25,7 +26,8 @@ type UpsertTables = | 'albums_shared_users_users' | 'memories' | 'memories_assets_assets' - | 'asset_stack'; + | 'asset_stack' + | 'person'; @Injectable() export class SyncRepository { @@ -42,6 +44,7 @@ export class SyncRepository { partnerAsset: PartnerAssetsSync; partnerAssetExif: PartnerAssetExifsSync; partnerStack: PartnerStackSync; + people: PersonSync; stack: StackSync; user: UserSync; @@ -59,6 +62,7 @@ export class SyncRepository { this.partnerAsset = new PartnerAssetsSync(this.db); this.partnerAssetExif = new PartnerAssetExifsSync(this.db); this.partnerStack = new PartnerStackSync(this.db); + this.people = new PersonSync(this.db); this.stack = new StackSync(this.db); this.user = new UserSync(this.db); } @@ -357,6 +361,41 @@ class AssetSync extends BaseSync { } } +class PersonSync extends BaseSync { + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('person_audit') + .select(['id', 'personId']) + .where('ownerId', '=', userId) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('person') + .select([ + 'id', + 'createdAt', + 'updatedAt', + 'ownerId', + 'name', + 'birthDate', + 'thumbnailPath', + 'isHidden', + 'isFavorite', + 'color', + 'updateId', + 'faceAssetId', + ]) + .where('ownerId', '=', userId) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } +} + class AssetExifSync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getUpserts(userId: string, ack?: SyncAck) { diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 1b7a4f884c..424ceb0d33 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -203,3 +203,16 @@ export const stacks_delete_audit = registerFunction({ RETURN NULL; END`, }); + +export const person_delete_audit = registerFunction({ + name: 'person_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO person_audit ("personId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END`, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 6512ccc225..f564e8c7f0 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -11,6 +11,7 @@ import { memories_delete_audit, memory_assets_delete_audit, partners_delete_audit, + person_delete_audit, stacks_delete_audit, updated_at, users_delete_audit, @@ -42,6 +43,7 @@ import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-coun import { NotificationTable } from 'src/schema/tables/notification.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; +import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; @@ -92,6 +94,7 @@ export class ImmichDatabase { PartnerAuditTable, PartnerTable, PersonTable, + PersonAuditTable, SessionTable, SharedLinkAssetTable, SharedLinkTable, @@ -124,6 +127,7 @@ export class ImmichDatabase { memories_delete_audit, memory_assets_delete_audit, stacks_delete_audit, + person_delete_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; @@ -166,6 +170,7 @@ export interface DB { partners_audit: PartnerAuditTable; partners: PartnerTable; person: PersonTable; + person_audit: PersonAuditTable; sessions: SessionTable; session_sync_checkpoints: SessionSyncCheckpointTable; shared_link__asset: SharedLinkAssetTable; diff --git a/server/src/schema/migrations/1752152941084-PeopleAuditTable.ts b/server/src/schema/migrations/1752152941084-PeopleAuditTable.ts new file mode 100644 index 0000000000..3a3da0ded5 --- /dev/null +++ b/server/src/schema/migrations/1752152941084-PeopleAuditTable.ts @@ -0,0 +1,41 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION person_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person_audit ("personId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "person_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "personId" uuid NOT NULL, + "ownerId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "PK_46c1ad23490b9312ffaa052aa59" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "IDX_person_audit_person_id" ON "person_audit" ("personId");`.execute(db); + await sql`CREATE INDEX "IDX_person_audit_owner_id" ON "person_audit" ("ownerId");`.execute(db); + await sql`CREATE INDEX "IDX_person_audit_deleted_at" ON "person_audit" ("deletedAt");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_audit" + AFTER DELETE ON "person" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION person_delete_audit();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_delete_audit', '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_delete_audit', '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "person_delete_audit" ON "person";`.execute(db); + await sql`DROP TABLE "person_audit";`.execute(db); + await sql`DROP FUNCTION person_delete_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_delete_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_delete_audit';`.execute(db); +} diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts new file mode 100644 index 0000000000..f5790629d8 --- /dev/null +++ b/server/src/schema/tables/person-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('person_audit') +export class PersonAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', indexName: 'IDX_person_audit_person_id' }) + personId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_person_audit_owner_id' }) + ownerId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_person_audit_deleted_at' }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 5835b2528c..193ecb8921 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,7 +1,9 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { person_delete_audit } from 'src/schema/functions'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Check, Column, CreateDateColumn, @@ -15,6 +17,12 @@ import { @Table('person') @UpdatedAtTrigger('person_updated_at') +@AfterDeleteTrigger({ + scope: 'statement', + function: person_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) @Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3322de2e1..5e9c679d7f 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -69,6 +69,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.PartnerAssetExifsV1, SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, + SyncRequestType.PeopleV1, ]; const throwSessionRequired = () => { @@ -141,6 +142,7 @@ export class SyncService extends BaseService { [SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth), [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth), }; for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { @@ -488,7 +490,7 @@ export class SyncService extends BaseService { private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { const deleteType = SyncEntityType.MemoryDeleteV1; - const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[SyncEntityType.MemoryDeleteV1]); + const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { send(response, { type: deleteType, ids: [id], data }); } @@ -576,6 +578,20 @@ export class SyncService extends BaseService { } } + private async syncPeopleV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.PersonDeleteV1; + const deletes = this.syncRepository.people.getDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.PersonV1; + const upserts = this.syncRepository.people.getUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) { const { type, sessionId, createId } = item; await this.syncCheckpointRepository.upsertAll([ diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts new file mode 100644 index 0000000000..807e41894c --- /dev/null +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -0,0 +1,87 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.PersonV1, () => { + it('should detect and sync the first person', async () => { + const { auth, ctx } = await setup(); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: person.id, + name: person.name, + thumbnailPath: person.thumbnailPath, + isHidden: person.isHidden, + birthDate: person.birthDate, + faceAssetId: person.faceAssetId, + isFavorite: person.isFavorite, + ownerId: auth.user.id, + color: person.color, + }), + type: 'PersonV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.PeopleV1])).resolves.toEqual([]); + }); + + it('should detect and sync a deleted person', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + await personRepo.delete([person.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + personId: person.id, + }, + type: 'PersonDeleteV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.PeopleV1])).resolves.toEqual([]); + }); + + it('should not sync a person or person delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.PeopleV1])).toHaveLength(0); + + await personRepo.delete([person.id]); + expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.PeopleV1])).toHaveLength(0); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 8d8d12c54e..e12b614f0d 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -17,6 +17,9 @@ "skipLibCheck": true, "esModuleInterop": true, "preserveWatchOutput": true, + "paths": { + "src/*": ["./src/*"], + }, "baseUrl": "./", "jsx": "react", "types": ["vitest/globals"],