From e88bd74fd2ab7ce8c3a07a2782e85f8d7598415f Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Tue, 10 Jun 2025 23:47:46 +1000 Subject: [PATCH] feat(server): add memories statistics resource (#19035) --- mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/memories_api.dart | 72 ++++++++++++++ mobile/openapi/lib/api_client.dart | 2 + .../model/memory_statistics_response_dto.dart | 99 +++++++++++++++++++ open-api/immich-openapi-specs.json | 77 +++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 21 ++++ server/src/controllers/memory.controller.ts | 14 ++- server/src/dtos/memory.dto.ts | 5 + server/src/queries/memory.repository.sql | 34 ++++++- server/src/repositories/memory.repository.ts | 38 ++++--- server/src/services/memory.service.ts | 4 + 12 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 mobile/openapi/lib/model/memory_statistics_response_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ee646354d1..c5cff5176a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -144,6 +144,7 @@ Class | Method | HTTP request | Description *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | *MemoriesApi* | [**deleteMemory**](doc//MemoriesApi.md#deletememory) | **DELETE** /memories/{id} | *MemoriesApi* | [**getMemory**](doc//MemoriesApi.md#getmemory) | **GET** /memories/{id} | +*MemoriesApi* | [**memoriesStatistics**](doc//MemoriesApi.md#memoriesstatistics) | **GET** /memories/statistics | *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | @@ -375,6 +376,7 @@ Class | Method | HTTP request | Description - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) + - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md) - [MemoryType](doc//MemoryType.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 9bf4026320..573081503f 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -163,6 +163,7 @@ part 'model/memories_response.dart'; part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_response_dto.dart'; +part 'model/memory_statistics_response_dto.dart'; part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index 88897d3038..9b62cce9c0 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -206,6 +206,78 @@ class MemoriesApi { return null; } + /// Performs an HTTP 'GET /memories/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/memories/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (for_ != null) { + queryParams.addAll(_queryParams('', 'for', for_)); + } + if (isSaved != null) { + queryParams.addAll(_queryParams('', 'isSaved', isSaved)); + } + if (isTrashed != null) { + queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { + final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemoryStatisticsResponseDto',) as MemoryStatisticsResponseDto; + + } + return null; + } + /// Performs an HTTP 'DELETE /memories/{id}/assets' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bc7f48255c..28b67f52c5 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -382,6 +382,8 @@ class ApiClient { return MemoryCreateDto.fromJson(value); case 'MemoryResponseDto': return MemoryResponseDto.fromJson(value); + case 'MemoryStatisticsResponseDto': + return MemoryStatisticsResponseDto.fromJson(value); case 'MemoryType': return MemoryTypeTypeTransformer().decode(value); case 'MemoryUpdateDto': diff --git a/mobile/openapi/lib/model/memory_statistics_response_dto.dart b/mobile/openapi/lib/model/memory_statistics_response_dto.dart new file mode 100644 index 0000000000..a9a10ad327 --- /dev/null +++ b/mobile/openapi/lib/model/memory_statistics_response_dto.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 MemoryStatisticsResponseDto { + /// Returns a new [MemoryStatisticsResponseDto] instance. + MemoryStatisticsResponseDto({ + required this.total, + }); + + int total; + + @override + bool operator ==(Object other) => identical(this, other) || other is MemoryStatisticsResponseDto && + other.total == total; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (total.hashCode); + + @override + String toString() => 'MemoryStatisticsResponseDto[total=$total]'; + + Map toJson() { + final json = {}; + json[r'total'] = this.total; + return json; + } + + /// Returns a new [MemoryStatisticsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MemoryStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryStatisticsResponseDto"); + if (value is Map) { + final json = value.cast(); + + return MemoryStatisticsResponseDto( + total: mapValueOfType(json, r'total')!, + ); + } + 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 = MemoryStatisticsResponseDto.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 = MemoryStatisticsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MemoryStatisticsResponseDto-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] = MemoryStatisticsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'total', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 775fe117ad..4f0fa44fa4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3599,6 +3599,72 @@ ] } }, + "/memories/statistics": { + "get": { + "operationId": "memoriesStatistics", + "parameters": [ + { + "name": "for", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "isSaved", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/MemoryType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memories" + ] + } + }, "/memories/{id}": { "delete": { "operationId": "deleteMemory", @@ -10827,6 +10893,17 @@ ], "type": "object" }, + "MemoryStatisticsResponseDto": { + "properties": { + "total": { + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, "MemoryType": { "enum": [ "on_this_day" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fee00acffd..417055e484 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -742,6 +742,9 @@ export type MemoryCreateDto = { seenAt?: string; "type": MemoryType; }; +export type MemoryStatisticsResponseDto = { + total: number; +}; export type MemoryUpdateDto = { isSaved?: boolean; memoryAt?: string; @@ -2509,6 +2512,24 @@ export function createMemory({ memoryCreateDto }: { body: memoryCreateDto }))); } +export function memoriesStatistics({ $for, isSaved, isTrashed, $type }: { + $for?: string; + isSaved?: boolean; + isTrashed?: boolean; + $type?: MemoryType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MemoryStatisticsResponseDto; + }>(`/memories/statistics${QS.query(QS.explode({ + "for": $for, + isSaved, + isTrashed, + "type": $type + }))}`, { + ...opts + })); +} export function deleteMemory({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 1f848ad705..d33c5ec22c 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -2,7 +2,13 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, import { ApiTags } from '@nestjs/swagger'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; +import { + MemoryCreateDto, + MemoryResponseDto, + MemorySearchDto, + MemoryStatisticsResponseDto, + MemoryUpdateDto, +} from 'src/dtos/memory.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MemoryService } from 'src/services/memory.service'; @@ -25,6 +31,12 @@ export class MemoryController { return this.service.create(auth, dto); } + @Get('statistics') + @Authenticated({ permission: Permission.MEMORY_READ }) + memoriesStatistics(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { + return this.service.statistics(auth, dto); + } + @Get(':id') @Authenticated({ permission: Permission.MEMORY_READ }) getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 98231a9035..675039363b 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -71,6 +71,11 @@ export class MemoryCreateDto extends MemoryBaseDto { assetIds?: string[]; } +export class MemoryStatisticsResponseDto { + @ApiProperty({ type: 'integer' }) + total!: number; +} + export class MemoryResponseDto { id!: string; createdAt!: Date; diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index a3243025b4..3cdda8a45c 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -1,8 +1,33 @@ -- NOTE: This file is auto generated by ./sql-generator +-- MemoryRepository.statistics +select + count(*) as "total" +from + "memories" +where + "deletedAt" is null + and "ownerId" = $1 + +-- MemoryRepository.statistics (date filter) +select + count(*) as "total" +from + "memories" +where + ( + "showAt" is null + or "showAt" <= $1 + ) + and ( + "hideAt" is null + or "hideAt" >= $2 + ) + and "deletedAt" is null + and "ownerId" = $3 + -- MemoryRepository.search select - "memories".*, ( select coalesce(json_agg(agg), '[]') @@ -20,7 +45,8 @@ select order by "assets"."fileCreatedAt" asc ) as agg - ) as "assets" + ) as "assets", + "memories".* from "memories" where @@ -31,7 +57,6 @@ order by -- MemoryRepository.search (date filter) select - "memories".*, ( select coalesce(json_agg(agg), '[]') @@ -49,7 +74,8 @@ select order by "assets"."fileCreatedAt" asc ) as agg - ) as "assets" + ) as "assets", + "memories".* from "memories" where diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 96eb78e6d6..0f29b3746f 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -28,14 +28,36 @@ export class MemoryRepository implements IBulkAsset { .execute(); } + searchBuilder(ownerId: string, dto: MemorySearchDto) { + return this.db + .selectFrom('memories') + .$if(dto.isSaved !== undefined, (qb) => qb.where('isSaved', '=', dto.isSaved!)) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.for !== undefined, (qb) => + qb + .where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)])) + .where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])), + ) + .where('deletedAt', dto.isTrashed ? 'is not' : 'is', null) + .where('ownerId', '=', ownerId); + } + + @GenerateSql( + { params: [DummyValue.UUID, {}] }, + { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, + ) + statistics(ownerId: string, dto: MemorySearchDto) { + return this.searchBuilder(ownerId, dto) + .select((qb) => qb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + } + @GenerateSql( { params: [DummyValue.UUID, {}] }, { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, ) search(ownerId: string, dto: MemorySearchDto) { - return this.db - .selectFrom('memories') - .selectAll('memories') + return this.searchBuilder(ownerId, dto) .select((eb) => jsonArrayFrom( eb @@ -48,15 +70,7 @@ export class MemoryRepository implements IBulkAsset { .where('assets.deletedAt', 'is', null), ).as('assets'), ) - .$if(dto.isSaved !== undefined, (qb) => qb.where('isSaved', '=', dto.isSaved!)) - .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) - .$if(dto.for !== undefined, (qb) => - qb - .where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)])) - .where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])), - ) - .where('deletedAt', dto.isTrashed ? 'is not' : 'is', null) - .where('ownerId', '=', ownerId) + .selectAll('memories') .orderBy('memoryAt', 'desc') .execute(); } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 1ccd311790..b0ea697edb 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -82,6 +82,10 @@ export class MemoryService extends BaseService { return memories.map((memory) => mapMemory(memory, auth)); } + statistics(auth: AuthDto, dto: MemorySearchDto) { + return this.memoryRepository.statistics(auth.user.id, dto); + } + async get(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); const memory = await this.findOrFail(id);