From ce5966c23df5ada4ed3643ed066649dcf5252612 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Wed, 1 Nov 2023 04:13:34 +0100 Subject: [PATCH] feat(web,server): activity (#4682) * feat: activity * regenerate api * fix: make asset owner unable to delete comment * fix: merge * fix: tests * feat: use textarea instead of input * fix: do actions only if the album is shared * fix: placeholder opacity * fix(web): improve messages UI * fix(web): improve input message UI * pr feedback * fix: tests * pr feedback * pr feedback * pr feedback * fix permissions * regenerate api * pr feedback * pr feedback * multiple improvements on web * fix: ui colors * WIP * chore: open api * pr feedback * fix: add comment * chore: clean up * pr feedback * refactor: endpoints * chore: open api * fix: filter by type * fix: e2e * feat: e2e remove own comment * fix: web tests * remove console.log * chore: cleanup * fix: ui tweaks * pr feedback * fix web test * fix: unit tests * chore: remove unused code * revert useless changes * fix: grouping messages * fix: remove nullable on updatedAt * fix: text overflow * styling --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 577 ++++++++++++++++++ mobile/openapi/.openapi-generator/FILES | 18 + mobile/openapi/README.md | Bin 22064 -> 22728 bytes mobile/openapi/doc/ActivityApi.md | Bin 0 -> 8794 bytes mobile/openapi/doc/ActivityCreateDto.md | Bin 0 -> 551 bytes mobile/openapi/doc/ActivityResponseDto.md | Bin 0 -> 605 bytes .../doc/ActivityStatisticsResponseDto.md | Bin 0 -> 424 bytes mobile/openapi/doc/ReactionType.md | Bin 0 -> 378 bytes mobile/openapi/doc/UserDto.md | Bin 0 -> 533 bytes mobile/openapi/lib/api.dart | Bin 7253 -> 7476 bytes mobile/openapi/lib/api/activity_api.dart | Bin 0 -> 7160 bytes mobile/openapi/lib/api_client.dart | Bin 21568 -> 22011 bytes mobile/openapi/lib/api_helper.dart | Bin 5328 -> 5430 bytes .../lib/model/activity_create_dto.dart | Bin 0 -> 4361 bytes .../lib/model/activity_response_dto.dart | Bin 0 -> 6838 bytes .../activity_statistics_response_dto.dart | Bin 0 -> 3042 bytes mobile/openapi/lib/model/reaction_type.dart | Bin 0 -> 2597 bytes mobile/openapi/lib/model/user_dto.dart | Bin 0 -> 3644 bytes mobile/openapi/test/activity_api_test.dart | Bin 0 -> 1091 bytes .../test/activity_create_dto_test.dart | Bin 0 -> 870 bytes .../test/activity_response_dto_test.dart | Bin 0 -> 1059 bytes ...activity_statistics_response_dto_test.dart | Bin 0 -> 608 bytes mobile/openapi/test/reaction_type_test.dart | Bin 0 -> 421 bytes mobile/openapi/test/user_dto_test.dart | Bin 0 -> 949 bytes server/immich-openapi-specs.json | 297 ++++++++- server/src/domain/access/access.core.ts | 17 + server/src/domain/activity/activity.dto.ts | 65 ++ .../src/domain/activity/activity.service.ts | 80 +++ server/src/domain/activity/activity.spec.ts | 168 +++++ server/src/domain/activity/index.ts | 2 + server/src/domain/domain.module.ts | 2 + server/src/domain/index.ts | 1 + .../domain/repositories/access.repository.ts | 4 + .../repositories/activity.repository.ts | 11 + server/src/domain/repositories/index.ts | 1 + .../user/response-dto/user-response.dto.ts | 19 +- server/src/immich/app.module.ts | 2 + .../immich/controllers/activity.controller.ts | 52 ++ server/src/immich/controllers/index.ts | 1 + server/src/infra/entities/activity.entity.ts | 51 ++ server/src/infra/entities/index.ts | 3 + server/src/infra/infra.module.ts | 3 + .../migrations/1698693294632-AddActivity.ts | 22 + .../infra/repositories/access.repository.ts | 22 + .../infra/repositories/activity.repository.ts | 64 ++ server/src/infra/repositories/index.ts | 1 + server/test/api/activity-api.ts | 14 + server/test/api/album-api.ts | 7 +- server/test/api/index.ts | 2 + server/test/e2e/activity.e2e-spec.ts | 376 ++++++++++++ server/test/fixtures/activity.stub.ts | 34 ++ .../repositories/access.repository.mock.ts | 5 + .../repositories/activity.repository.mock.ts | 10 + web/src/api/api.ts | 3 + web/src/api/open-api/api.ts | 577 ++++++++++++++++++ .../components/album-page/album-viewer.svelte | 5 +- .../asset-viewer/activity-viewer.svelte | 289 +++++++++ .../asset-viewer/asset-viewer.svelte | 166 ++++- .../asset-viewer/photo-viewer.svelte | 8 +- .../components/photos-page/asset-grid.svelte | 8 +- .../individual-shared-viewer.svelte | 5 +- .../gallery-viewer/gallery-viewer.svelte | 4 +- .../shared-components/user-avatar.svelte | 12 +- web/src/lib/utils/asset-utils.ts | 13 +- web/src/lib/utils/timesince.ts | 9 + .../(user)/albums/[albumId]/+page.svelte | 10 +- 66 files changed, 3002 insertions(+), 38 deletions(-) create mode 100644 mobile/openapi/doc/ActivityApi.md create mode 100644 mobile/openapi/doc/ActivityCreateDto.md create mode 100644 mobile/openapi/doc/ActivityResponseDto.md create mode 100644 mobile/openapi/doc/ActivityStatisticsResponseDto.md create mode 100644 mobile/openapi/doc/ReactionType.md create mode 100644 mobile/openapi/doc/UserDto.md create mode 100644 mobile/openapi/lib/api/activity_api.dart create mode 100644 mobile/openapi/lib/model/activity_create_dto.dart create mode 100644 mobile/openapi/lib/model/activity_response_dto.dart create mode 100644 mobile/openapi/lib/model/activity_statistics_response_dto.dart create mode 100644 mobile/openapi/lib/model/reaction_type.dart create mode 100644 mobile/openapi/lib/model/user_dto.dart create mode 100644 mobile/openapi/test/activity_api_test.dart create mode 100644 mobile/openapi/test/activity_create_dto_test.dart create mode 100644 mobile/openapi/test/activity_response_dto_test.dart create mode 100644 mobile/openapi/test/activity_statistics_response_dto_test.dart create mode 100644 mobile/openapi/test/reaction_type_test.dart create mode 100644 mobile/openapi/test/user_dto_test.dart create mode 100644 server/src/domain/activity/activity.dto.ts create mode 100644 server/src/domain/activity/activity.service.ts create mode 100644 server/src/domain/activity/activity.spec.ts create mode 100644 server/src/domain/activity/index.ts create mode 100644 server/src/domain/repositories/activity.repository.ts create mode 100644 server/src/immich/controllers/activity.controller.ts create mode 100644 server/src/infra/entities/activity.entity.ts create mode 100644 server/src/infra/migrations/1698693294632-AddActivity.ts create mode 100644 server/src/infra/repositories/activity.repository.ts create mode 100644 server/test/api/activity-api.ts create mode 100644 server/test/e2e/activity.e2e-spec.ts create mode 100644 server/test/fixtures/activity.stub.ts create mode 100644 server/test/repositories/activity.repository.mock.ts create mode 100644 web/src/lib/components/asset-viewer/activity-viewer.svelte create mode 100644 web/src/lib/utils/timesince.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 97dc8523c3..f64a592b54 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -99,6 +99,103 @@ export interface APIKeyUpdateDto { */ 'name': string; } +/** + * + * @export + * @interface ActivityCreateDto + */ +export interface ActivityCreateDto { + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'albumId': string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'assetId'?: string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'comment'?: string; + /** + * + * @type {ReactionType} + * @memberof ActivityCreateDto + */ + 'type': ReactionType; +} + + +/** + * + * @export + * @interface ActivityResponseDto + */ +export interface ActivityResponseDto { + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'assetId': string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'comment'?: string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'type': ActivityResponseDtoTypeEnum; + /** + * + * @type {UserDto} + * @memberof ActivityResponseDto + */ + 'user': UserDto; +} + +export const ActivityResponseDtoTypeEnum = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ActivityResponseDtoTypeEnum = typeof ActivityResponseDtoTypeEnum[keyof typeof ActivityResponseDtoTypeEnum]; + +/** + * + * @export + * @interface ActivityStatisticsResponseDto + */ +export interface ActivityStatisticsResponseDto { + /** + * + * @type {number} + * @memberof ActivityStatisticsResponseDto + */ + 'comments': number; +} /** * * @export @@ -2490,6 +2587,20 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const ReactionType = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ReactionType = typeof ReactionType[keyof typeof ReactionType]; + + /** * * @export @@ -4248,6 +4359,43 @@ export interface UsageByUserDto { */ 'videos': number; } +/** + * + * @export + * @interface UserDto + */ +export interface UserDto { + /** + * + * @type {string} + * @memberof UserDto + */ + 'email': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'firstName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'lastName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'profileImagePath': string; +} /** * * @export @@ -4831,6 +4979,435 @@ export class APIKeyApi extends BaseAPI { } +/** + * ActivityApi - axios parameter creator + * @export + */ +export const ActivityApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity: async (activityCreateDto: ActivityCreateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'activityCreateDto' is not null or undefined + assertParamExists('createActivity', 'activityCreateDto', activityCreateDto) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(activityCreateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteActivity', 'id', id) + const localVarPath = `/activity/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivities', 'albumId', albumId) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics: async (albumId: string, assetId?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivityStatistics', 'albumId', albumId) + const localVarPath = `/activity/statistics`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ActivityApi - functional programming interface + * @export + */ +export const ActivityApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ActivityApiAxiosParamCreator(configuration) + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createActivity(activityCreateDto: ActivityCreateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createActivity(activityCreateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteActivity(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteActivity(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivities(albumId: string, assetId?: string, type?: ReactionType, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivityStatistics(albumId: string, assetId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivityStatistics(albumId, assetId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * ActivityApi - factory interface + * @export + */ +export const ActivityApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ActivityApiFp(configuration) + return { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createActivity(requestParameters.activityCreateDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteActivity(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createActivity operation in ActivityApi. + * @export + * @interface ActivityApiCreateActivityRequest + */ +export interface ActivityApiCreateActivityRequest { + /** + * + * @type {ActivityCreateDto} + * @memberof ActivityApiCreateActivity + */ + readonly activityCreateDto: ActivityCreateDto +} + +/** + * Request parameters for deleteActivity operation in ActivityApi. + * @export + * @interface ActivityApiDeleteActivityRequest + */ +export interface ActivityApiDeleteActivityRequest { + /** + * + * @type {string} + * @memberof ActivityApiDeleteActivity + */ + readonly id: string +} + +/** + * Request parameters for getActivities operation in ActivityApi. + * @export + * @interface ActivityApiGetActivitiesRequest + */ +export interface ActivityApiGetActivitiesRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly assetId?: string + + /** + * + * @type {ReactionType} + * @memberof ActivityApiGetActivities + */ + readonly type?: ReactionType +} + +/** + * Request parameters for getActivityStatistics operation in ActivityApi. + * @export + * @interface ActivityApiGetActivityStatisticsRequest + */ +export interface ActivityApiGetActivityStatisticsRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly assetId?: string +} + +/** + * ActivityApi - object-oriented interface + * @export + * @class ActivityApi + * @extends {BaseAPI} + */ +export class ActivityApi extends BaseAPI { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).createActivity(requestParameters.activityCreateDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).deleteActivity(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * AlbumApi - axios parameter creator * @export diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index c73dcfd065..52b863a6f2 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -8,6 +8,10 @@ doc/APIKeyCreateDto.md doc/APIKeyCreateResponseDto.md doc/APIKeyResponseDto.md doc/APIKeyUpdateDto.md +doc/ActivityApi.md +doc/ActivityCreateDto.md +doc/ActivityResponseDto.md +doc/ActivityStatisticsResponseDto.md doc/AddUsersDto.md doc/AdminSignupResponseDto.md doc/AlbumApi.md @@ -97,6 +101,7 @@ doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md +doc/ReactionType.md doc/RecognitionConfig.md doc/ScanLibraryDto.md doc/SearchAlbumResponseDto.md @@ -158,11 +163,13 @@ doc/UpdateTagDto.md doc/UpdateUserDto.md doc/UsageByUserDto.md doc/UserApi.md +doc/UserDto.md doc/UserResponseDto.md doc/ValidateAccessTokenResponseDto.md doc/VideoCodec.md git_push.sh lib/api.dart +lib/api/activity_api.dart lib/api/album_api.dart lib/api/api_key_api.dart lib/api/asset_api.dart @@ -187,6 +194,9 @@ lib/auth/authentication.dart lib/auth/http_basic_auth.dart lib/auth/http_bearer_auth.dart lib/auth/oauth.dart +lib/model/activity_create_dto.dart +lib/model/activity_response_dto.dart +lib/model/activity_statistics_response_dto.dart lib/model/add_users_dto.dart lib/model/admin_signup_response_dto.dart lib/model/album_count_response_dto.dart @@ -271,6 +281,7 @@ lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart +lib/model/reaction_type.dart lib/model/recognition_config.dart lib/model/scan_library_dto.dart lib/model/search_album_response_dto.dart @@ -326,10 +337,15 @@ lib/model/update_stack_parent_dto.dart lib/model/update_tag_dto.dart lib/model/update_user_dto.dart lib/model/usage_by_user_dto.dart +lib/model/user_dto.dart lib/model/user_response_dto.dart lib/model/validate_access_token_response_dto.dart lib/model/video_codec.dart pubspec.yaml +test/activity_api_test.dart +test/activity_create_dto_test.dart +test/activity_response_dto_test.dart +test/activity_statistics_response_dto_test.dart test/add_users_dto_test.dart test/admin_signup_response_dto_test.dart test/album_api_test.dart @@ -424,6 +440,7 @@ test/person_response_dto_test.dart test/person_statistics_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart +test/reaction_type_test.dart test/recognition_config_test.dart test/scan_library_dto_test.dart test/search_album_response_dto_test.dart @@ -485,6 +502,7 @@ test/update_tag_dto_test.dart test/update_user_dto_test.dart test/usage_by_user_dto_test.dart test/user_api_test.dart +test/user_dto_test.dart test/user_response_dto_test.dart test/validate_access_token_response_dto_test.dart test/video_codec_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9e5462b084bcffa46fe97165485d5b9ddb1ff686..d11037497e4ba8bae5c915dc4d4506872b1f077f 100644 GIT binary patch delta 595 zcmdn6hVjHk#tp$-^~oifWtk{DCmOGPoqMB(u0AGZ~jP zNVZfK!xaN{6>4WtDne-Ue2>%I NTnMu_hlSi?1OO8;)64(> delta 33 pcmX@Hk#WNs#tp$-lV$k$HZ$^^(A!*X-N?UL#rpvFW~T6Ci~!du3?Bdh diff --git a/mobile/openapi/doc/ActivityApi.md b/mobile/openapi/doc/ActivityApi.md new file mode 100644 index 0000000000000000000000000000000000000000..8ae91efc29d8ea444a0d1712e516a60e03f214c3 GIT binary patch literal 8794 zcmeHMZBN@s5dQ98F%l=mv4{h0b?VU`QW|nC;#DDhs5k_UJqdd_UZ1^b$pQcUp7q*^ z69P(0(Lpbf5YF!G?Ci{Vo@X{IBx8=eSh)JH;Vbb)s8J&pmbJ1%yV46tT`)Bcw<-4g zORvw?`T2R^CCUN?yX0aOhzVCMC(yqK;+l zvAU1}-O#5_=MWNq4>?u%y2YuVh*$|3S+(*9bLFqK+3(hgk2sJ2Xz^NVwKUg)!J=!T4inZYZ2V$D*LQ*djzL* ztiyb+4)aFg$JRXCCW=N!3a(9+@E=wZ@<3>5<23zQRsAktlzykm=Y!++ z8TL8bX?;dQ!-iqx%fSFDsLP@JsuMY7HH;}vxF@d2X1DLs2S&ChRqDy4Fmshek0R!P zgU8!;Xf}g|W+!9YV*bJD&fvE1Fd|?1JUujP+(ItON{(^iY#Md55bBUOy_9L)rDOa% zQi+KA)RC7QE#|>bO2uY}Jk5+^I1zc%@O@6xOq-o2TeI0ZIy&nd?6meP)hy+*{a=~= z|3LofG^ei@!oQ$CN1UM0Aq`=WtF6r~9LZsl>HTO#WKt}(t8`7S>(cEHKEVB3;v7~_ zL|#ZbN+LpuiG0QjRuqv&ENlqnGI>!RmyMF(cnso%(-sQG<5|s8$%w8k!pCS#I7K0V zn)3FpKZOxNOgF4zk)?^6x#0o*85`#`d=xiNhYP=<5mEUl0Z7s+^J*O}l zQzl5Rlc8V^?%gu=+ zP4rV;Gb|DJr)i>1?f5>}&HHdubsnQa*DI{gP`%B%)s&I$q_Tc-g6$7==?j&_AK$4@ zZTl?vK#m4;NrCR;O|<4spUCW9!V6DHclrAwL;K`E`fLsXU&Wl2$%6earsVR_Gg|2_YIOi@ag o3yTqu2TR^lQjpTdKM@s~ODl@DcrN^usl{A@7xE**|A1ut3naNz&#-y*y^CLho7Al){c$U7xP=6 ze#uh7(SuXoR2DUiRXvb-H*~xYpa>d5#)^VJpwgUPflmL*=8iW{ literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/ActivityResponseDto.md b/mobile/openapi/doc/ActivityResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..3b8589b61e93de79733b24c9767737e95fd050a1 GIT binary patch literal 605 zcma)3!D<3A5WVLs0((#cyWZQ=mh~W15o=G&f?+eYp(YcOu^{y0J6SEY#Zq(0yqP!e zO)`bV0A2@MIW(}Z>YnVorQ;e>K%bx?6rw2PBWfe*734sZLHBQZ2V%S38XZYk7ci0> zo%45R(PVWE^x z321JVVy;q86s_M4fH9YQbPhaSEw?yKqW9gvjGzfjed4xCH4Hb_fl}T0E3TZGu<%LS z{^?5m+^d#6(IG(`YHab$F@Jw{IENPoqHP~|2a-@O^5W#)ChEFzRZZ8kx>?MaTNvyQ o`GGqBY59V&9JZGx^1HiU&2Q}GU6$ZYK{zVqbXnt^PEl6j(H grucOZ-|As|_fM@X!(j56WR4#eUk!gXpDU$40dPNr5&!@I literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/ReactionType.md b/mobile/openapi/doc/ReactionType.md new file mode 100644 index 0000000000000000000000000000000000000000..0cc41e23a940c3e1197f7e8b33d3ce3e97d41e5f GIT binary patch literal 378 zcma)1!D_=W487|s1UYm$*u8J3j`T2CLMgkIKuoN*X0>G;?J(HK&&~pEciD6iPkMS! zuR@L#Omy0_rHkH^8O3pU`-}uoRrt!rqMQlmJw`LWn-jq>4Avw8=OZV<)iQrv)Ye6@ z5LQoxIxFo`UM6ugL1#SS7Z#J;8k)xBVzJGrW62D4Q+arUl==$;91hRIsv{>gC!A`$UT=0U|Gdq46$KmF1-X;>G5lp-1Hd=? C0d%|o literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/UserDto.md b/mobile/openapi/doc/UserDto.md new file mode 100644 index 0000000000000000000000000000000000000000..617ceb9d3bb5a52753df07404e5ecfa2e287c737 GIT binary patch literal 533 zcma)3O-lnY5WVMD4D6vcknLShmF=Nm%i_nW6gF(K?cgR8lJOw;<4x9rRjiszc=O)8 z8QxSt!FnCAc4X{WKP4L*CLyW{?noQpqlJlrp!;Wiu%hcaqazD+fg;1~RKGqMEs9wY z%x;Qwa%y8HBcn}XtDS{6czg}zYi#Nx21fE3ZHco&oxNN@~ delta 31 ncmdmDb=6|Sf2Pf{%u!66f3w>1Y>pJ-;ha2)U19S=>B)=$$ioX- diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..fa04c68b5ac6b41c94927bd0dca24ca62496d08a GIT binary patch literal 7160 zcmeHLZBH9V5dQ98G0BJ6uC8{9@}VLD>VQd*C{W@kst|IPyD{GMym#4M0r(YWeaw6G}6ohdd#Ne8f@O2jsQknuoQ~1a)tf1OW{63Nui!7U;^F`21n35 zI{YwzF7vx= z{CA79W_YTcHw=YXh3D!~!&rHuFjC!jhpYZ1*K^ncF-=oqPwiK$0B&8|gz3s00`MYxdH_B${Bw-%kOJzd+RhS$%?&U#`(Q^p zXDm4;QAMTOo=8AqAhVf~nf|vJbE6f@3`Kr#hGAsa@ZjxSqblaM{YI`VM2MfC#z?9| zz?x(lL7}`rStyXm;LK2IMlwK?+=h+XuDyH}!HnjQ86{n0nzYe41g?!vS;F?TRWQvb zXqL<**Tfqd<0^vBrXsBqR>+NjGjSf|@U3EJuJ_Xqv5 z2z4)ca&>Q&$Vu5L@Z`37lTg#4F`T+qZ=4abao<(Noz;d7pd&F)S&Y3jZ8dEWB63MI z31D;GQc=NfEj*8-$lSH0INUtCqH-G`FEXBNl5kYP*jfd^6$PQbx&~!~VMo@Q{`}t+ zpe)A=`tYv*BRbTO29};pCwte$ff@rMooNrKnJuO&g?cYpXm*d$RM||J_URNfeJ<^1 z&ry4E;izgk@!9oTiQv+AbaWkXE+eS=*s?w%xOzA(?n6-;fJ$%KVM$`NbXXZ{HJeo0 ze5C2C{}!4N!wDaApjvCHS6N#tW^OR;GGZ^5)Hi^hRL$Q`YlrDWRtEA86yKn;wd zPH;sde^-qq?mb$8{n5G-w7%x5CM$)%07ZJC5cI^V zl-H4mSPyL9{&XX_hZ{j1HsO7Qy*I9I|6PYr-17Z(;fDJ}xU3laB(N*F(KIy+%+GD# z^-|v(vlUEgRVL}otZIdJ_z-YqSeJyVl=Om37`fU`fEYI8WkBzBl; zA~?@YO*iXEPmyLz1WM=UO`fPP!4Z@SwL>6)>OP8hZYPr(J?bQ&) zT5RlORk?|ge^tsry%bCQT?vE#P8tj2#_m_ob!nX_-9*jcP|cMzUfu3>R;W_iNukRt zh`EI*CqF%#YPAdZdjN6c1=&G2}LR?FlN?Agv zd|FmTA*O{qpiipICE}H2S273ixo}FC#5{<`XIfe|4G8k0nn{2(lXFoOj;=*fN$OUX zLt`V4;!gij9l=pX3%!=t(qvxbN|w?9{#i6{b!8Aju3>puSx0kG6f_n27eay%w?8(e zkcn~OB=EW4fHq)^Au*c;u{{AG?mG_X9Y7!3fQ-?LhG9P7{gS{N&e{Kf|5t#=lJJ0k zlmaEI%M0Y(C*H5V(jb#T-jJrL)&&B+hOZrFjL)_TL&Gkhu=^6DC!#MXuIu<1`mJ657K|D{Z2c*C;g-kSKO4d%${B^*h$ZE07(|xlMlYkWv7b$t z+j9sU@?;+)|Cd=HuCMF0va#XE8&DrZOU`kp@YfMj<>)DP!rf+q+lf>81Q(fwo^e@) zg?8(B>4Wf`iX02UrJQqO!q`OPK}2btFmfBz^CG=mSl1$9H(Us~Jw+s271Kq4;U^Nb zPfM}-gQxXqet|?pqr)yUJ)ErG{aOuyx5GhtxK0IgOHfJst?U-+c47WW`b(LJBn}f zVe0NA5vyjC(%9o~O4@WB20k3=0qOLXdS0-RoO{v=sK!C@i`?+6^7?_7?EO2rg=(Wo zb85JkdYE*$YOR%9V2O)}A6~6IJPmSCi+!4}-FQRrLk;G(z4Sw4J~NZ{6w{D3cyBL1 zGJ06K!bX!hclr7^{COZX9zwI&!yB#OUKZhCyLoOUqsM8g!E2BytE+Wew^_}wK#-lH zhpz@Pj`8f2H3#a4Fz9A+F%rl$KkcTHC#;2At_4BwK8?=#$rbyvAG`G{`d8MiFJZ$Q Uk4Jsu3bQ7R0{={Y(Lsm#8?tPFTmS$7 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..0f4cbd481367a5df7d8a58430d20ab088bffe6cc GIT binary patch literal 6838 zcmd5=ZEqVl68`RAF$RoEM%8uN4~IfM*)`72)w#wl68nIIAPDp>WwBZ9>PT(`A@zU1 zXZW%&)>?K79PR={mdN4EaORmeo}3(d z()*i>tGE9;1Q}EQoC$3wKP2D(aKJy+Ls4ouUzB>jP`MmaRa8c1bD7JPEbJ)$R;zWX zZSM(@Z^YKj*Oi@X`R__;952KQpKGD<*Gg@`T$}#mX;~O63OiRB1geFS+8sB&!g5te zGtbN70c3V5iusRk=IK&sb8rB0mdXW{Ygvf3O7Q=ugM%~|#?X0c)n{e5AEa4BpuDgp zeK{aHfek)LTj|1(ph9kF+Cw@c<&;cif-^k?L*_*k?@xBAOai&mUS%V<8u5X+}Z`?->Q|QT9p8EIJ5v@=%18V{6(XnKu|yNvq5kstle4p0ki&9~%0us;$b}q-sUC!j z3})cg&LN)7Bgx#zAQ_P8JH>7f)Lqfh)z66z`;F+ZuwQih)Kk&5ezoCS%%d+7Ti)4p zc#OYSru^|QZ@!Oi_kwFCDr-)2GU9w&Z?XrQZT|IyfZf^<@GnoGUG8^=f0#es!J=>*IF)6=3Ir; zRf>FR=A23AD40l)bJW&lnQ`d*g}%yk=G1VQ4H)+7Md&d!opPL3p;KHZ>Ok9(B&jfV z2joLvX4o6Snmw^Pgp&SWfcThJV*O7x)Xm}+{t9eQha5{IYC_@ImpDEg!1m(N2+H=+ zh&OjQmbzM4gU}HT2FHhuOqIPT4#;euVDydqT*24mLYXiyoJaVsh3I6i!0a)*|8(XxWUw(;#u_Vws_8;6QRcd|m#ekiMz+8o+Jj->UT)BV zInri7BGjXI(r59;a!@~5i7nNDekU99w!cgwvdo7@>!i1Fv~|bg;gl`;X2WQ(F~x6= zOWPgo`qF1Lj*wobL>qYHfT~@4`Z%@gP_#d!?=`=pn|Sv;7xze&VS*S_-R#d1;TS8p z(e+(P$CX&s+vv=Me<^9=h1mH1Qm=BV6_S%vP&#+IPoOXOL4zy@7SgXLz?4 zCnI`Y@PbDhLC;J3GdOxBL|y~8P(xaY;YBpzYF%&lxFlhB)dp;VYE;K=6UCz%X&xw@ z6aZ;0fSl0nQr1|gXE(skpt%$9cLkW-OG2GD5X(^^=-tPgYo0Pg&UVGW9xVUCXX{4u z^oz4d-8K5EHE0zxzmEbHwp^tN-(3~q?`TqymYyG!W8JC;<|{w3D-4WKn1^P`^PdAh zrV-qy{EQ(kqtN#7b^JYgtRJ~2bKfGzaK;&K#Mp#Ykx89zF~#~|<iL!w!~dRhti!Dqg+T&XNsXBylPQMex=1(DpG#r&HO~IaEl(}zf7SX# z1^ypfAY>aC+R>L7X3!J*hdP%SQ&A8g%+8%*x&o{bH$^HL_Ip9i#ni{}$cf;|Z0b=m zi!(fGtrc`H(oamimtFF5QhWkRmm?Q~XSMBDVVNJk=Jh?#&iGXdxBdzqNT5DR{ON-s z`+36;_e7#Ip?jBMTlx;H&QBLEDBX-3CkdBYW>Y4f`F>{)b52ZvhySE2cpRCo++5>f z0|PWlr>;V2P)sZxG4TY$-ya~0zc=L=XgB)ommPS&pn;doD8DQ4a^N4^Wp=4`sr@CFm{^LgDYz6qu?6(p;<2#EwzT2f zfaCdZSA+*~Jdnc$uu`i)J2$WiG1;IxW4_XIg$D)qy37_~dTt9qp(SdSLW!vi*Ih6! zb1+-4n=;P}9HOk{9)F%uNYl|(41?I3)~-bzWXINHC3o>IP6fFVWV z-5{3Rad7EA)sjHWmD&FbOkBx*6$hqlj%RWqFb9uoety9ay)&e! z$f&ENF_2iM=Eif*3^^JNMkBcRI-kA%dwM;+xtLEc;p)TZX$Y4yxSq}7%k1*Q)xSq* zMwahWrp)+F^yixa{S>QGDjsK2#hEDhDb!`Hc^dPAuX$-g|68mpsmwtSR&3Z-$5m}& z#s9B`Lidub@qfh>ejBbd23Pv9dm>A1SZQLBVnUG#uB^L0n5+;b*Kr}s6`GlxmGRp@ z;v{EE4+fadfXqNvykwP#@b7FeND8JkTqH)^3$wj7%m{5nqHnmaFg3q45*`KsBShnd zn_86`7?gYii(aQC2wQl!Z?0RtNF%XZ3_n>v?9LoY z)?%TCKErT{=6lpSLuEx}t!ZILOut-gz{(U{H);?@!#yi%t|3!$-2`6PCDdnz218Qw z0=K;kp{xs(5b0$jw3Tg#rb_V)d&;(>suL8Mj!Awob|>~rjE$VjlxTAc{gQuY3$m~_ zFuOuM##mcm%Oq7|b$%US4}EJAB}|*2cnF+QTw_t&_7@aj;qK%4I)yc>{vq^c*?e2^ zbJX%AAlgIduzGSt9ZDZeNMAeqzhRXiZCP!A`HTbRyjPzSDA48#DceKH<$kc8zEX0- z?h0%u$XKCyxbqM>fE3qtVMw6;nHEb|H33fX32!P8j*qP*BcZRQnXW6d^$CyzoG?L? zM{aN~S<}b&o+ZcNxF7k5q-pnwTWT;Q93LfcG`7<0#;0jX8;n?*I-ztt9Rr?;Jw6&Q zBWaI;2k{jAlZ|3qupz5E5c-iMxg4a-*X;m9tM1t7kLia=mmRFAmmJID*pc_E&cFfF z9POvjp29I6gm~WDQ>T%1hiIJ3YU`6j|%;U8Vwc0>&DC(S<#nRMsnMwlFzr~&D+(96$j=Yt`dIg3XH`qCAOLL-O= zR7A>qqA4fjO?C(M6vFdH0LJJl?Kyd0qPJ&C={d4|f$DIR`&#HFEFxN4P&?ua+X?VE z3&K{BPEdP;1oL);G=h1~Tg|i=Ih3cyBcGufYLwF*2RubJrlme&@bPwbO_#$)B=%@+ ZZ-Jl4x4mCX9xOJGmkE0h7oCm$%C=m#gI!T;F_JPT=YeZtqs``R?lG`r8?q zvE|2HSU-Q4{PJ$ZuX3x6#k4RsEu_L3)Vg+L+*?I-QKEGuLE$s0oL#lfXj?Nf{Y z)Y8&*ElT>Vgr#qf){%4TPG8TAc3xRcnkPythxLX|7ST9_Xf zVb7bRb1DL0a7oEWJavPAXY;1!PtpYlgi=r$=j4OJn4|-#W9e(|@n+ozBs6vqFLcXM z#NYaMAJ!CyAqW*r!V71>Pn(cLzg?2OzCED>t zl4E9a9QS~2{Bxbd%od>Nx9j6be-j!L`2Jvwf~_@s!3~*1A(X?(^F?4{3%^BIE5>E$ zc}nCnC?ghQ)gT&!K?!JHA=;wbkt{Z@8p!+oD<5tPuZs^vr5C zg$^F#Gj$@4Hpzg8OlTlxFXBkF2L!P)H*T9=*m?pue&@|r>=?%0D$%Pm2AhJic z9^Ow*D9RCa0;~lIjLNE+8|UjqJ{H5+fZHDXX6OBulwKjDTtPeNe!x5SkV)7IOXgqHfv;(FRA z;i!#=2ZRVo4zA8Jbgm#L2Of0h0o~Vug6uUi3G)R0Fnc7$obaq1g$@(eKTt?9ORNTG9T5CrKGtPcFyT5 zeDq?UV uY-7BlcC4D5AIVuH@>fDgp(iaHh|JL-6R#RMW57=AxFh?;i>AD1>HHTBcUx=# literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_dto.dart b/mobile/openapi/lib/model/user_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..e96588cf53eded28626a4024e731a931eaa6ca7d GIT binary patch literal 3644 zcmbVPZExE)5dQ98aRq|f!Blzcry-rZ7E3auYvQ3z2Mk6aFcNLElSPlDYZz(%`|kLr z7dv&Z0I{WeA@81h?nsZvgYg*NeZHN(`u*bO;^Vv9i*vZV`f!oJ`4n!Zw{Sf@zqysHNihT&jF7Dt-o=dZT%n^NO!{ZIbXVR%@xuK@V1K*-q!{ zjmZ`NcOw+K*KCEKYo_qqaHVl^r4PFovet&xCKn}cDCUAI>#h%OR*9PHypr_-&CHV3 z`OiP*#gZvK7~pmWxX$jwI88W_}k3p202 z17U+Hc#3vjI3gc_kQCFD-UX;n2h-T{6&B#(c=2dFdL7lC=2Q6^BGg z8K1i*{k+MvFWbXH)CLyZz>?|ZZ?fc=z0D_f6g5)K3$8U&J8A^v_L6I%t=Y&HEe+nM z@PiY_?Dkcn+?R!n_BH(k8jHPDJdKQW4~MQO{JAjSY1Z%>)Qrg@hZEAn6(Qvu_;w8t16&oU+@H;E@{t zIr9rxU4k3h8idjCl~o(AVXoxLFEO(s9C>pz7}6-5VG-^Us5cchI(79{Xgdj8v|cMd z$I)x2w%Qap3Up3GBF7$ zs=75v3!IpH^&h#hNLBHug#nJixIfE?Ggz_pUj)Z=e!E+9ztChv#7ZFW=JbV|)#MpD zanv_j4%_~sJnD$_YI_9w9Q6x^9q&Guj;_*oXsw2*5(JO-(*sKkiFEXJ{vfa>9`OlO z_;!h0>lEAp{9q4~g_2u#U*Y70Ijb~J9z(@ly%pD+%21z&HD`A&5@HIiL`0!Yapc?n zNQJ(U=3=!ryRZ~i4v(slUr}!Hc(vp#uS}+OsowhuPW2 z7I*nBw6+GBgZ`<8#98I9m}d}ggE?Nv@ZL%HOLrW0%7jw23x!JQO>iBOpc=SDo-CO4 zPp!HI|B=EWEHmf$58u&=Vf`K7XwUCd4_#v`V7B;5qtt>U-34hY%o0V^5_CQFn7?vb zH1@+8Pdl`t>p3mb4AAS=$w&-|4KX1*k{qmi0?%tST%*m1?hgNl7tbvDbLis-cdLny zvIEoUR?@?r68sUU7D literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..401264c2bb7ad85d934eaa94df75a02b6ee7a8d7 GIT binary patch literal 1091 zcmb7?O>f&U42JLi72GKfkkwmHL%*Kw(ib4!`2z<5M5@ z3XpryKanX&*Qvr2K;O>};V+{dKrifD3S!?@o~2ozxZU+|po7xij3bbHwxlYc@|kj{ z@>iF;x+TY>6S;v3c|Qh17wzRkc fxo629p9*(!ln3|jp!hHU3t*4zUI-nIcN_f%W)x=2 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/activity_create_dto_test.dart b/mobile/openapi/test/activity_create_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..263f1e27d7963fc9f0c75c2a04f2cb1f1e64d7ba GIT binary patch literal 870 zcmbV~K~Li_5QXpg6~n1TDh+T#Af&BGL6v|=kUelRv&l5INNi(IKoR1ya=7pPLyP)mj<+g`WMdfuzR<{kT>T*u1OYp1=)4YpDpdU-SX zAm%>omzUNAM#D;15~!D&{2&i|!y0WUsK%OFFkYciXUD2sp$|#2o)Jb4%NMNa+&VJo zbSlp@Zauo#L6`!kv21}D8v6ipB`_;lziIBTJRwuYHhHtUf)=$IAHqihAox-nY(NiN zlmwT%s`24PB5dt#=fz}IyWI5dC1^mjh_07X4#UlB=HZ0c{ z=U*?9c%;TU#&YAh+)#^`(Az#>U1EzJT3H;<)Op7;sW9!c8A9oMDH;Fv#45L@)72Z+ zw;%^K=P)g=oDE{ElvHaVZ3uakhsm&(EQYdmwoy#B#+Ki{l-1U7NRnNRF!QGRV;a14 z9T@2uo?X2|1cRh;IRG&FkpGBfb~ zWs+q{mcjUaS=>HP=99&EImuxX;f*y@U@F4`gOk9RvrYDv)=+CQoi6+&07Bi6(G^hp zq7q?rU7^z*Cc@i#+qg~|ZLj0g9{gFgW@H#jTgQJ2A_Hx3(XnjVr?=(%yE#_9iB&GGzbVMA?JbhkGsu zzLS$A%S4vJ=A|mHAB%l)*i=Oh+uc)Zu9lVD~q`iRyE@q0%|4AyVh(MlU)G%5?Th_6o8 z{}4~kXHhOdEREhl_!2k})?R6@PKLyXRt*fOcs7SGApkn7M>9(z{)bFbxFF~GT8Ojw E2A?C2vj6}9 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/user_dto_test.dart b/mobile/openapi/test/user_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..fa1b7da8669a86636fba8fc4e2dcbcaf33084fde GIT binary patch literal 949 zcmbV~QBT4!6oudSE3QvKf=qcb5E4lShD4cBc<`w-+uhcxx3>+$APix;Ud5Mpv*qk*x|&5WU)<075XG>JSMU%=i}}k1VUD~_xwezx;CdLi zQsh!;WSP<|6D5wIk&VHWVTlz=+iz=8)k@og3SNHjtzmUznZ~z9Xp&1_(OPp&Z;RTH zxi*L8iIT>0X_-hVP-FtNmz#rOrI2V?sbo%=Ex2SiS1c*GHbJoO5jr(bUU`lqRios! z2S_Zt5?CA5kyXLF5-C(%$Ziil0|0_DA$bYH_nsh3TDIF8 z2SnsrHFdY+tlB@FK77l)!L!vu=73~dk^p=xDU|;JTOib$wxI16HlEt-9k|)b#JAy8 z)XFN3V(+lqXaBbMb6?Ua9F6!P&xsM~QGb!1V_b4$Pinsf&k3P9yUV|*=+-=PTO8fX OU%_diWb0387kmK#1|<^! literal 0 HcmV?d00001 diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 6f8d639e9c..2ed01f30e0 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1,6 +1,194 @@ { "openapi": "3.0.0", "paths": { + "/activity": { + "get": { + "operationId": "getActivities", + "parameters": [ + { + "name": "albumId", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "assetId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/ReactionType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ActivityResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + }, + "post": { + "operationId": "createActivity", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + } + }, + "/activity/statistics": { + "get": { + "operationId": "getActivityStatistics", + "parameters": [ + { + "name": "albumId", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "assetId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + } + }, + "/activity/{id}": { + "delete": { + "operationId": "deleteActivity", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + } + }, "/album": { "get": { "operationId": "getAllAlbums", @@ -5512,6 +5700,77 @@ ], "type": "object" }, + "ActivityCreateDto": { + "properties": { + "albumId": { + "format": "uuid", + "type": "string" + }, + "assetId": { + "format": "uuid", + "type": "string" + }, + "comment": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/ReactionType" + } + }, + "required": [ + "albumId", + "type" + ], + "type": "object" + }, + "ActivityResponseDto": { + "properties": { + "assetId": { + "nullable": true, + "type": "string" + }, + "comment": { + "nullable": true, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "comment", + "like" + ], + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserDto" + } + }, + "required": [ + "id", + "createdAt", + "type", + "user", + "assetId" + ], + "type": "object" + }, + "ActivityStatisticsResponseDto": { + "properties": { + "comments": { + "type": "integer" + } + }, + "required": [ + "comments" + ], + "type": "object" + }, "AddUsersDto": { "properties": { "sharedUserIds": { @@ -7432,6 +7691,13 @@ ], "type": "object" }, + "ReactionType": { + "enum": [ + "comment", + "like" + ], + "type": "string" + }, "RecognitionConfig": { "properties": { "enabled": { @@ -8757,6 +9023,33 @@ ], "type": "object" }, + "UserDto": { + "properties": { + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "profileImagePath": { + "type": "string" + } + }, + "required": [ + "id", + "firstName", + "lastName", + "email", + "profileImagePath" + ], + "type": "object" + }, "UserResponseDto": { "properties": { "createdAt": { @@ -8810,12 +9103,12 @@ }, "required": [ "id", - "email", "firstName", "lastName", + "email", + "profileImagePath", "storageLabel", "externalPath", - "profileImagePath", "shouldChangePassword", "isAdmin", "createdAt", diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 1ad9712509..4142527784 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -3,6 +3,9 @@ import { AuthUserDto } from '../auth'; import { IAccessRepository } from '../repositories'; export enum Permission { + ACTIVITY_CREATE = 'activity.create', + ACTIVITY_DELETE = 'activity.delete', + // ASSET_CREATE = 'asset.create', ASSET_READ = 'asset.read', ASSET_UPDATE = 'asset.update', @@ -133,6 +136,20 @@ export class AccessCore { private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) { switch (permission) { + // uses album id + case Permission.ACTIVITY_CREATE: + return ( + (await this.repository.album.hasOwnerAccess(authUser.id, id)) || + (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) + ); + + // uses activity id + case Permission.ACTIVITY_DELETE: + return ( + (await this.repository.activity.hasOwnerAccess(authUser.id, id)) || + (await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id)) + ); + case Permission.ASSET_READ: return ( (await this.repository.asset.hasOwnerAccess(authUser.id, id)) || diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts new file mode 100644 index 0000000000..894c9b29c0 --- /dev/null +++ b/server/src/domain/activity/activity.dto.ts @@ -0,0 +1,65 @@ +import { ActivityEntity } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; +import { Optional, ValidateUUID } from '../domain.util'; +import { UserDto, mapSimpleUser } from '../user/response-dto'; + +export enum ReactionType { + COMMENT = 'comment', + LIKE = 'like', +} + +export type MaybeDuplicate = { duplicate: boolean; value: T }; + +export class ActivityResponseDto { + id!: string; + createdAt!: Date; + type!: ReactionType; + user!: UserDto; + assetId!: string | null; + comment?: string | null; +} + +export class ActivityStatisticsResponseDto { + @ApiProperty({ type: 'integer' }) + comments!: number; +} + +export class ActivityDto { + @ValidateUUID() + albumId!: string; + + @ValidateUUID({ optional: true }) + assetId?: string; +} + +export class ActivitySearchDto extends ActivityDto { + @IsEnum(ReactionType) + @Optional() + @ApiProperty({ enumName: 'ReactionType', enum: ReactionType }) + type?: ReactionType; +} + +const isComment = (dto: ActivityCreateDto) => dto.type === 'comment'; + +export class ActivityCreateDto extends ActivityDto { + @IsEnum(ReactionType) + @ApiProperty({ enumName: 'ReactionType', enum: ReactionType }) + type!: ReactionType; + + @ValidateIf(isComment) + @IsNotEmpty() + @IsString() + comment?: string; +} + +export function mapActivity(activity: ActivityEntity): ActivityResponseDto { + return { + id: activity.id, + assetId: activity.assetId, + createdAt: activity.createdAt, + comment: activity.comment, + type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, + user: mapSimpleUser(activity.user), + }; +} diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts new file mode 100644 index 0000000000..e5af6169a1 --- /dev/null +++ b/server/src/domain/activity/activity.service.ts @@ -0,0 +1,80 @@ +import { ActivityEntity } from '@app/infra/entities'; +import { Inject, Injectable } from '@nestjs/common'; +import { AccessCore, Permission } from '../access'; +import { AuthUserDto } from '../auth'; +import { IAccessRepository, IActivityRepository } from '../repositories'; +import { + ActivityCreateDto, + ActivityDto, + ActivityResponseDto, + ActivitySearchDto, + ActivityStatisticsResponseDto, + MaybeDuplicate, + ReactionType, + mapActivity, +} from './activity.dto'; + +@Injectable() +export class ActivityService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IActivityRepository) private repository: IActivityRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise { + await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId); + const activities = await this.repository.search({ + albumId: dto.albumId, + assetId: dto.assetId, + isLiked: dto.type && dto.type === ReactionType.LIKE, + }); + + return activities.map(mapActivity); + } + + async getStatistics(authUser: AuthUserDto, dto: ActivityDto): Promise { + await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId); + return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) }; + } + + async create(authUser: AuthUserDto, dto: ActivityCreateDto): Promise> { + await this.access.requirePermission(authUser, Permission.ACTIVITY_CREATE, dto.albumId); + + const common = { + userId: authUser.id, + assetId: dto.assetId, + albumId: dto.albumId, + }; + + let activity: ActivityEntity | null = null; + let duplicate = false; + + if (dto.type === 'like') { + delete dto.comment; + [activity] = await this.repository.search({ + ...common, + isLiked: true, + }); + duplicate = !!activity; + } + + if (!activity) { + activity = await this.repository.create({ + ...common, + isLiked: dto.type === ReactionType.LIKE, + comment: dto.comment, + }); + } + + return { duplicate, value: mapActivity(activity) }; + } + + async delete(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.ACTIVITY_DELETE, id); + await this.repository.delete(id); + } +} diff --git a/server/src/domain/activity/activity.spec.ts b/server/src/domain/activity/activity.spec.ts new file mode 100644 index 0000000000..968f7421a3 --- /dev/null +++ b/server/src/domain/activity/activity.spec.ts @@ -0,0 +1,168 @@ +import { BadRequestException } from '@nestjs/common'; +import { authStub, IAccessRepositoryMock, newAccessRepositoryMock } from '@test'; +import { activityStub } from '@test/fixtures/activity.stub'; +import { newActivityRepositoryMock } from '@test/repositories/activity.repository.mock'; +import { IActivityRepository } from '../repositories'; +import { ReactionType } from './activity.dto'; +import { ActivityService } from './activity.service'; + +describe(ActivityService.name, () => { + let sut: ActivityService; + let accessMock: IAccessRepositoryMock; + let activityMock: jest.Mocked; + + beforeEach(async () => { + accessMock = newAccessRepositoryMock(); + activityMock = newActivityRepositoryMock(); + + sut = new ActivityService(accessMock, activityMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getAll', () => { + it('should get all', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([]); + + await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); + + expect(activityMock.search).toHaveBeenCalledWith({ + assetId: 'asset-id', + albumId: 'album-id', + isLiked: undefined, + }); + }); + + it('should filter by type=like', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([]); + + await expect( + sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }), + ).resolves.toEqual([]); + + expect(activityMock.search).toHaveBeenCalledWith({ + assetId: 'asset-id', + albumId: 'album-id', + isLiked: true, + }); + }); + + it('should filter by type=comment', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([]); + + await expect( + sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }), + ).resolves.toEqual([]); + + expect(activityMock.search).toHaveBeenCalledWith({ + assetId: 'asset-id', + albumId: 'album-id', + isLiked: false, + }); + }); + }); + + describe('getStatistics', () => { + it('should get the comment count', async () => { + activityMock.getStatistics.mockResolvedValue(1); + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + await expect( + sut.getStatistics(authStub.admin, { + assetId: 'asset-id', + albumId: activityStub.oneComment.albumId, + }), + ).resolves.toEqual({ comments: 1 }); + }); + }); + + describe('addComment', () => { + it('should require access to the album', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.COMMENT, + comment: 'comment', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should create a comment', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.create.mockResolvedValue(activityStub.oneComment); + + await sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.COMMENT, + comment: 'comment', + }); + + expect(activityMock.create).toHaveBeenCalledWith({ + userId: 'admin_id', + albumId: 'album-id', + assetId: 'asset-id', + comment: 'comment', + isLiked: false, + }); + }); + + it('should create a like', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.create.mockResolvedValue(activityStub.liked); + activityMock.search.mockResolvedValue([]); + + await sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.LIKE, + }); + + expect(activityMock.create).toHaveBeenCalledWith({ + userId: 'admin_id', + albumId: 'album-id', + assetId: 'asset-id', + isLiked: true, + }); + }); + + it('should skip if like exists', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([activityStub.liked]); + + await sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.LIKE, + }); + + expect(activityMock.create).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should require access', async () => { + accessMock.activity.hasOwnerAccess.mockResolvedValue(false); + await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException); + expect(activityMock.delete).not.toHaveBeenCalled(); + }); + + it('should let the activity owner delete a comment', async () => { + accessMock.activity.hasOwnerAccess.mockResolvedValue(true); + await sut.delete(authStub.admin, 'activity-id'); + expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + }); + + it('should let the album owner delete a comment', async () => { + accessMock.activity.hasAlbumOwnerAccess.mockResolvedValue(true); + await sut.delete(authStub.admin, 'activity-id'); + expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + }); + }); +}); diff --git a/server/src/domain/activity/index.ts b/server/src/domain/activity/index.ts new file mode 100644 index 0000000000..f0d954014f --- /dev/null +++ b/server/src/domain/activity/index.ts @@ -0,0 +1,2 @@ +export * from './activity.dto'; +export * from './activity.service'; diff --git a/server/src/domain/domain.module.ts b/server/src/domain/domain.module.ts index dc00c692e9..d03fd27d45 100644 --- a/server/src/domain/domain.module.ts +++ b/server/src/domain/domain.module.ts @@ -1,4 +1,5 @@ import { DynamicModule, Global, Module, ModuleMetadata, OnApplicationShutdown, Provider } from '@nestjs/common'; +import { ActivityService } from './activity'; import { AlbumService } from './album'; import { APIKeyService } from './api-key'; import { AssetService } from './asset'; @@ -21,6 +22,7 @@ import { TagService } from './tag'; import { UserService } from './user'; const providers: Provider[] = [ + ActivityService, AlbumService, APIKeyService, AssetService, diff --git a/server/src/domain/index.ts b/server/src/domain/index.ts index f2b05ac76d..e76159d400 100644 --- a/server/src/domain/index.ts +++ b/server/src/domain/index.ts @@ -1,4 +1,5 @@ export * from './access'; +export * from './activity'; export * from './album'; export * from './api-key'; export * from './asset'; diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 7584762fc6..43b53e605d 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -1,6 +1,10 @@ export const IAccessRepository = 'IAccessRepository'; export interface IAccessRepository { + activity: { + hasOwnerAccess(userId: string, albumId: string): Promise; + hasAlbumOwnerAccess(userId: string, albumId: string): Promise; + }; asset: { hasOwnerAccess(userId: string, assetId: string): Promise; hasAlbumAccess(userId: string, assetId: string): Promise; diff --git a/server/src/domain/repositories/activity.repository.ts b/server/src/domain/repositories/activity.repository.ts new file mode 100644 index 0000000000..6f5476a289 --- /dev/null +++ b/server/src/domain/repositories/activity.repository.ts @@ -0,0 +1,11 @@ +import { ActivityEntity } from '@app/infra/entities/activity.entity'; +import { ActivitySearch } from '@app/infra/repositories'; + +export const IActivityRepository = 'IActivityRepository'; + +export interface IActivityRepository { + search(options: ActivitySearch): Promise; + create(activity: Partial): Promise; + delete(id: string): Promise; + getStatistics(assetId: string | undefined, albumId: string): Promise; +} diff --git a/server/src/domain/repositories/index.ts b/server/src/domain/repositories/index.ts index 2c4a10cc24..ff098d8dbb 100644 --- a/server/src/domain/repositories/index.ts +++ b/server/src/domain/repositories/index.ts @@ -1,4 +1,5 @@ export * from './access.repository'; +export * from './activity.repository'; export * from './album.repository'; export * from './api-key.repository'; export * from './asset.repository'; diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts index 59a387de1b..b9f990378a 100644 --- a/server/src/domain/user/response-dto/user-response.dto.ts +++ b/server/src/domain/user/response-dto/user-response.dto.ts @@ -1,13 +1,16 @@ import { UserEntity } from '@app/infra/entities'; -export class UserResponseDto { +export class UserDto { id!: string; - email!: string; firstName!: string; lastName!: string; + email!: string; + profileImagePath!: string; +} + +export class UserResponseDto extends UserDto { storageLabel!: string | null; externalPath!: string | null; - profileImagePath!: string; shouldChangePassword!: boolean; isAdmin!: boolean; createdAt!: Date; @@ -17,15 +20,21 @@ export class UserResponseDto { memoriesEnabled?: boolean; } -export function mapUser(entity: UserEntity): UserResponseDto { +export const mapSimpleUser = (entity: UserEntity): UserDto => { return { id: entity.id, email: entity.email, firstName: entity.firstName, lastName: entity.lastName, + profileImagePath: entity.profileImagePath, + }; +}; + +export function mapUser(entity: UserEntity): UserResponseDto { + return { + ...mapSimpleUser(entity), storageLabel: entity.storageLabel, externalPath: entity.externalPath, - profileImagePath: entity.profileImagePath, shouldChangePassword: entity.shouldChangePassword, isAdmin: entity.isAdmin, createdAt: entity.createdAt, diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 25771d147a..cf537c6b92 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -13,6 +13,7 @@ import { FileUploadInterceptor } from './app.interceptor'; import { AppService } from './app.service'; import { APIKeyController, + ActivityController, AlbumController, AppController, AssetController, @@ -39,6 +40,7 @@ import { TypeOrmModule.forFeature([AssetEntity]), ], controllers: [ + ActivityController, AssetController, AssetControllerV1, AppController, diff --git a/server/src/immich/controllers/activity.controller.ts b/server/src/immich/controllers/activity.controller.ts new file mode 100644 index 0000000000..d6c2ea7629 --- /dev/null +++ b/server/src/immich/controllers/activity.controller.ts @@ -0,0 +1,52 @@ +import { AuthUserDto } from '@app/domain'; +import { + ActivityDto, + ActivitySearchDto, + ActivityService, + ActivityCreateDto as CreateDto, + ActivityResponseDto as ResponseDto, + ActivityStatisticsResponseDto as StatsResponseDto, +} from '@app/domain/activity'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { AuthUser, Authenticated } from '../app.guard'; +import { UseValidation } from '../app.utils'; +import { UUIDParamDto } from './dto/uuid-param.dto'; + +@ApiTags('Activity') +@Controller('activity') +@Authenticated() +@UseValidation() +export class ActivityController { + constructor(private service: ActivityService) {} + + @Get() + getActivities(@AuthUser() authUser: AuthUserDto, @Query() dto: ActivitySearchDto): Promise { + return this.service.getAll(authUser, dto); + } + + @Get('statistics') + getActivityStatistics(@AuthUser() authUser: AuthUserDto, @Query() dto: ActivityDto): Promise { + return this.service.getStatistics(authUser, dto); + } + + @Post() + async createActivity( + @AuthUser() authUser: AuthUserDto, + @Body() dto: CreateDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { duplicate, value } = await this.service.create(authUser, dto); + if (duplicate) { + res.status(HttpStatus.OK); + } + return value; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteActivity(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(authUser, id); + } +} diff --git a/server/src/immich/controllers/index.ts b/server/src/immich/controllers/index.ts index fd6f0b01ef..b54a63d860 100644 --- a/server/src/immich/controllers/index.ts +++ b/server/src/immich/controllers/index.ts @@ -1,3 +1,4 @@ +export * from './activity.controller'; export * from './album.controller'; export * from './api-key.controller'; export * from './app.controller'; diff --git a/server/src/infra/entities/activity.entity.ts b/server/src/infra/entities/activity.entity.ts new file mode 100644 index 0000000000..255a3a7084 --- /dev/null +++ b/server/src/infra/entities/activity.entity.ts @@ -0,0 +1,51 @@ +import { + Check, + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AlbumEntity } from './album.entity'; +import { AssetEntity } from './asset.entity'; +import { UserEntity } from './user.entity'; + +@Entity('activity') +@Index('IDX_activity_like', ['assetId', 'userId', 'albumId'], { unique: true, where: '("isLiked" = true)' }) +@Check(`("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`) +export class ActivityEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column() + albumId!: string; + + @Column() + userId!: string; + + @Column({ nullable: true, type: 'uuid' }) + assetId!: string | null; + + @Column({ type: 'text', default: null }) + comment!: string | null; + + @Column({ type: 'boolean', default: false }) + isLiked!: boolean; + + @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + asset!: AssetEntity | null; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + user!: UserEntity; + + @ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + album!: AlbumEntity; +} diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index ef4d635b05..cbe2bf6c37 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -1,3 +1,4 @@ +import { ActivityEntity } from './activity.entity'; import { AlbumEntity } from './album.entity'; import { APIKeyEntity } from './api-key.entity'; import { AssetFaceEntity } from './asset-face.entity'; @@ -15,6 +16,7 @@ import { TagEntity } from './tag.entity'; import { UserTokenEntity } from './user-token.entity'; import { UserEntity } from './user.entity'; +export * from './activity.entity'; export * from './album.entity'; export * from './api-key.entity'; export * from './asset-face.entity'; @@ -33,6 +35,7 @@ export * from './user-token.entity'; export * from './user.entity'; export const databaseEntities = [ + ActivityEntity, AlbumEntity, APIKeyEntity, AssetEntity, diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 367458169f..ffbedafff5 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -1,5 +1,6 @@ import { IAccessRepository, + IActivityRepository, IAlbumRepository, IAssetRepository, IAuditRepository, @@ -35,6 +36,7 @@ import { bullConfig, bullQueues } from './infra.config'; import { APIKeyRepository, AccessRepository, + ActivityRepository, AlbumRepository, AssetRepository, AuditRepository, @@ -60,6 +62,7 @@ import { } from './repositories'; const providers: Provider[] = [ + { provide: IActivityRepository, useClass: ActivityRepository }, { provide: IAccessRepository, useClass: AccessRepository }, { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAssetRepository, useClass: AssetRepository }, diff --git a/server/src/infra/migrations/1698693294632-AddActivity.ts b/server/src/infra/migrations/1698693294632-AddActivity.ts new file mode 100644 index 0000000000..46041570ea --- /dev/null +++ b/server/src/infra/migrations/1698693294632-AddActivity.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddActivity1698693294632 implements MigrationInterface { + name = 'AddActivity1698693294632' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "activity" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "assetId" uuid, "comment" text, "isLiked" boolean NOT NULL DEFAULT false, CONSTRAINT "CHK_2ab1e70f113f450eb40c1e3ec8" CHECK (("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)), CONSTRAINT "PK_24625a1d6b1b089c8ae206fe467" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_activity_like" ON "activity" ("assetId", "userId", "albumId") WHERE ("isLiked" = true)`); + await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_8091ea76b12338cb4428d33d782" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_1af8519996fbfb3684b58df280b" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_1af8519996fbfb3684b58df280b"`); + await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea"`); + await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_8091ea76b12338cb4428d33d782"`); + await queryRunner.query(`DROP INDEX "public"."IDX_activity_like"`); + await queryRunner.query(`DROP TABLE "activity"`); + } + +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 50085c4aad..566514796d 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -2,6 +2,7 @@ import { IAccessRepository } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { + ActivityEntity, AlbumEntity, AssetEntity, LibraryEntity, @@ -13,6 +14,7 @@ import { export class AccessRepository implements IAccessRepository { constructor( + @InjectRepository(ActivityEntity) private activityRepository: Repository, @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(AlbumEntity) private albumRepository: Repository, @InjectRepository(LibraryEntity) private libraryRepository: Repository, @@ -22,6 +24,26 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(UserTokenEntity) private tokenRepository: Repository, ) {} + activity = { + hasOwnerAccess: (userId: string, activityId: string): Promise => { + return this.activityRepository.exist({ + where: { + id: activityId, + userId, + }, + }); + }, + hasAlbumOwnerAccess: (userId: string, activityId: string): Promise => { + return this.activityRepository.exist({ + where: { + id: activityId, + album: { + ownerId: userId, + }, + }, + }); + }, + }; library = { hasOwnerAccess: (userId: string, libraryId: string): Promise => { return this.libraryRepository.exist({ diff --git a/server/src/infra/repositories/activity.repository.ts b/server/src/infra/repositories/activity.repository.ts new file mode 100644 index 0000000000..271124db5d --- /dev/null +++ b/server/src/infra/repositories/activity.repository.ts @@ -0,0 +1,64 @@ +import { IActivityRepository } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ActivityEntity } from '../entities/activity.entity'; + +export interface ActivitySearch { + albumId?: string; + assetId?: string; + userId?: string; + isLiked?: boolean; +} + +@Injectable() +export class ActivityRepository implements IActivityRepository { + constructor(@InjectRepository(ActivityEntity) private repository: Repository) {} + + search(options: ActivitySearch): Promise { + const { userId, assetId, albumId, isLiked } = options; + return this.repository.find({ + where: { + userId, + assetId, + albumId, + isLiked, + }, + relations: { + user: true, + }, + order: { + createdAt: 'ASC', + }, + }); + } + + create(entity: Partial): Promise { + return this.save(entity); + } + + async delete(id: string): Promise { + await this.repository.delete(id); + } + + getStatistics(assetId: string, albumId: string): Promise { + return this.repository.count({ + where: { assetId, albumId, isLiked: false }, + relations: { + user: true, + }, + }); + } + + private async save(entity: Partial) { + const { id } = await this.repository.save(entity); + return this.repository.findOneOrFail({ + where: { + id, + }, + relations: { + user: true, + }, + }); + } +} diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index bc2fba7666..81ea7dd81f 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -1,4 +1,5 @@ export * from './access.repository'; +export * from './activity.repository'; export * from './album.repository'; export * from './api-key.repository'; export * from './asset.repository'; diff --git a/server/test/api/activity-api.ts b/server/test/api/activity-api.ts new file mode 100644 index 0000000000..f7cac45624 --- /dev/null +++ b/server/test/api/activity-api.ts @@ -0,0 +1,14 @@ +import { ActivityCreateDto, ActivityResponseDto } from '@app/domain'; +import request from 'supertest'; + +export const activityApi = { + create: async (server: any, accessToken: string, dto: ActivityCreateDto) => { + const res = await request(server).post('/activity').set('Authorization', `Bearer ${accessToken}`).send(dto); + expect(res.status === 200 || res.status === 201).toBe(true); + return res.body as ActivityResponseDto; + }, + delete: async (server: any, accessToken: string, id: string) => { + const res = await request(server).delete(`/activity/${id}`).set('Authorization', `Bearer ${accessToken}`); + expect(res.status).toEqual(204); + }, +}; diff --git a/server/test/api/album-api.ts b/server/test/api/album-api.ts index 3364c34527..70a016da16 100644 --- a/server/test/api/album-api.ts +++ b/server/test/api/album-api.ts @@ -1,4 +1,4 @@ -import { AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain'; +import { AddUsersDto, AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain'; import request from 'supertest'; export const albumApi = { @@ -15,4 +15,9 @@ export const albumApi = { expect(res.status).toEqual(200); return res.body as BulkIdResponseDto[]; }, + addUsers: async (server: any, accessToken: string, id: string, dto: AddUsersDto) => { + const res = await request(server).put(`/album/${id}/users`).set('Authorization', `Bearer ${accessToken}`).send(dto); + expect(res.status).toEqual(200); + return res.body as AlbumResponseDto; + }, }; diff --git a/server/test/api/index.ts b/server/test/api/index.ts index f04a3a2096..d9321df275 100644 --- a/server/test/api/index.ts +++ b/server/test/api/index.ts @@ -1,3 +1,4 @@ +import { activityApi } from './activity-api'; import { albumApi } from './album-api'; import { assetApi } from './asset-api'; import { authApi } from './auth-api'; @@ -6,6 +7,7 @@ import { sharedLinkApi } from './shared-link-api'; import { userApi } from './user-api'; export const api = { + activityApi, authApi, assetApi, libraryApi, diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/test/e2e/activity.e2e-spec.ts new file mode 100644 index 0000000000..c488630ec8 --- /dev/null +++ b/server/test/e2e/activity.e2e-spec.ts @@ -0,0 +1,376 @@ +import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain'; +import { ActivityController } from '@app/immich'; +import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub, uuidStub } from '@test/fixtures'; +import { testApp } from '@test/test-utils'; +import request from 'supertest'; + +describe(`${ActivityController.name} (e2e)`, () => { + let server: any; + let admin: LoginResponseDto; + let asset: AssetFileUploadResponseDto; + let album: AlbumResponseDto; + + beforeAll(async () => { + [server] = await testApp.create(); + }); + + afterAll(async () => { + await testApp.teardown(); + }); + + beforeEach(async () => { + await db.reset(); + await api.authApi.adminSignUp(server); + admin = await api.authApi.adminLogin(server); + asset = await api.assetApi.upload(server, admin.accessToken, 'example'); + album = await api.albumApi.create(server, admin.accessToken, { albumName: 'Album 1', assetIds: [asset.id] }); + }); + + describe('GET /activity', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/activity'); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require an albumId', async () => { + const { status, body } = await request(server) + .get('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); + }); + + it('should reject an invalid albumId', async () => { + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: uuidStub.invalid }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); + }); + + it('should reject an invalid assetId', async () => { + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['assetId must be a UUID']))); + }); + + it('should start off empty', async () => { + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should filter by album id', async () => { + const album2 = await api.albumApi.create(server, admin.accessToken, { + albumName: 'Album 2', + assetIds: [asset.id], + }); + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.LIKE, + }), + api.activityApi.create(server, admin.accessToken, { + albumId: album2.id, + type: ReactionType.LIKE, + }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + + it('should filter by type=comment', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'comment', + }), + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id, type: 'comment' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + + it('should filter by type=like', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'comment', + }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id, type: 'like' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + + it('should filter by assetId', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }), + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id, assetId: asset.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + }); + + describe('POST /activity', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).post('/activity'); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require an albumId', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: uuidStub.invalid }); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); + }); + + it('should require a comment when type is comment', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: uuidStub.notFound, type: 'comment', comment: null }); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(['comment must be a string', 'comment should not be empty'])); + }); + + it('should add a comment to an album', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, type: 'comment', comment: 'This is my first comment' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: null, + createdAt: expect.any(String), + type: 'comment', + comment: 'This is my first comment', + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should add a like to an album', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, type: 'like' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: null, + createdAt: expect.any(String), + type: 'like', + comment: null, + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should return a 200 for a duplicate like on the album', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.LIKE, + }); + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, type: 'like' }); + expect(status).toEqual(200); + expect(body).toEqual(reaction); + }); + + it('should add a comment to an asset', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, assetId: asset.id, type: 'comment', comment: 'This is my first comment' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: asset.id, + createdAt: expect.any(String), + type: 'comment', + comment: 'This is my first comment', + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should add a like to an asset', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, assetId: asset.id, type: 'like' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: asset.id, + createdAt: expect.any(String), + type: 'like', + comment: null, + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should return a 200 for a duplicate like on an asset', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, assetId: asset.id, type: 'like' }); + expect(status).toEqual(200); + expect(body).toEqual(reaction); + }); + }); + + describe('DELETE /activity/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).delete(`/activity/${uuidStub.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(server) + .delete(`/activity/${uuidStub.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['id must be a UUID'])); + }); + + it('should remove a comment from an album', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(204); + }); + + it('should remove a like from an album', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.LIKE, + }); + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(204); + }); + + it('should let the owner remove a comment by another user', async () => { + const { id: userId } = await api.userApi.create(server, admin.accessToken, { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }); + await api.albumApi.addUsers(server, admin.accessToken, album.id, { sharedUserIds: [userId] }); + const nonOwner = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); + const reaction = await api.activityApi.create(server, nonOwner.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(204); + }); + + it('should not let a user remove a comment by another user', async () => { + const { id: userId } = await api.userApi.create(server, admin.accessToken, { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }); + await api.albumApi.addUsers(server, admin.accessToken, album.id, { sharedUserIds: [userId] }); + const nonOwner = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + const { status, body } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${nonOwner.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest('Not found or no activity.delete access')); + }); + + it('should let a non-owner remove their own comment', async () => { + const { id: userId } = await api.userApi.create(server, admin.accessToken, { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }); + await api.albumApi.addUsers(server, admin.accessToken, album.id, { sharedUserIds: [userId] }); + const nonOwner = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); + const reaction = await api.activityApi.create(server, nonOwner.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${nonOwner.accessToken}`); + expect(status).toBe(204); + }); + }); +}); diff --git a/server/test/fixtures/activity.stub.ts b/server/test/fixtures/activity.stub.ts new file mode 100644 index 0000000000..91c699cec3 --- /dev/null +++ b/server/test/fixtures/activity.stub.ts @@ -0,0 +1,34 @@ +import { ActivityEntity } from '@app/infra/entities'; +import { albumStub } from './album.stub'; +import { assetStub } from './asset.stub'; +import { authStub } from './auth.stub'; +import { userStub } from './user.stub'; + +export const activityStub = { + oneComment: Object.freeze({ + id: 'activity-1', + comment: 'comment', + isLiked: false, + userId: authStub.admin.id, + user: userStub.admin, + assetId: assetStub.image.id, + asset: assetStub.image, + albumId: albumStub.oneAsset.id, + album: albumStub.oneAsset, + createdAt: new Date(), + updatedAt: new Date(), + }), + liked: Object.freeze({ + id: 'activity-2', + comment: null, + isLiked: true, + userId: authStub.admin.id, + user: userStub.admin, + assetId: assetStub.image.id, + asset: assetStub.image, + albumId: albumStub.oneAsset.id, + album: albumStub.oneAsset, + createdAt: new Date(), + updatedAt: new Date(), + }), +}; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 9fbf7922d2..4f7992e86e 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,6 +1,7 @@ import { AccessCore, IAccessRepository } from '@app/domain'; export interface IAccessRepositoryMock { + activity: jest.Mocked; asset: jest.Mocked; album: jest.Mocked; authDevice: jest.Mocked; @@ -15,6 +16,10 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => } return { + activity: { + hasOwnerAccess: jest.fn(), + hasAlbumOwnerAccess: jest.fn(), + }, asset: { hasOwnerAccess: jest.fn(), hasAlbumAccess: jest.fn(), diff --git a/server/test/repositories/activity.repository.mock.ts b/server/test/repositories/activity.repository.mock.ts new file mode 100644 index 0000000000..349fa46361 --- /dev/null +++ b/server/test/repositories/activity.repository.mock.ts @@ -0,0 +1,10 @@ +import { IActivityRepository } from '@app/domain'; + +export const newActivityRepositoryMock = (): jest.Mocked => { + return { + search: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + getStatistics: jest.fn(), + }; +}; diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 9beb370d35..3a3584ed4d 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -20,12 +20,14 @@ import { UserApi, UserApiFp, AuditApi, + ActivityApi, } from './open-api'; import { BASE_PATH } from './open-api/base'; import { DUMMY_BASE_URL, toPathString } from './open-api/common'; import type { ApiParams } from './types'; export class ImmichApi { + public activityApi: ActivityApi; public albumApi: AlbumApi; public libraryApi: LibraryApi; public assetApi: AssetApi; @@ -52,6 +54,7 @@ export class ImmichApi { constructor(params: ConfigurationParameters) { this.config = new Configuration(params); + this.activityApi = new ActivityApi(this.config); this.albumApi = new AlbumApi(this.config); this.auditApi = new AuditApi(this.config); this.libraryApi = new LibraryApi(this.config); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 97dc8523c3..f64a592b54 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -99,6 +99,103 @@ export interface APIKeyUpdateDto { */ 'name': string; } +/** + * + * @export + * @interface ActivityCreateDto + */ +export interface ActivityCreateDto { + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'albumId': string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'assetId'?: string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'comment'?: string; + /** + * + * @type {ReactionType} + * @memberof ActivityCreateDto + */ + 'type': ReactionType; +} + + +/** + * + * @export + * @interface ActivityResponseDto + */ +export interface ActivityResponseDto { + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'assetId': string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'comment'?: string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'type': ActivityResponseDtoTypeEnum; + /** + * + * @type {UserDto} + * @memberof ActivityResponseDto + */ + 'user': UserDto; +} + +export const ActivityResponseDtoTypeEnum = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ActivityResponseDtoTypeEnum = typeof ActivityResponseDtoTypeEnum[keyof typeof ActivityResponseDtoTypeEnum]; + +/** + * + * @export + * @interface ActivityStatisticsResponseDto + */ +export interface ActivityStatisticsResponseDto { + /** + * + * @type {number} + * @memberof ActivityStatisticsResponseDto + */ + 'comments': number; +} /** * * @export @@ -2490,6 +2587,20 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const ReactionType = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ReactionType = typeof ReactionType[keyof typeof ReactionType]; + + /** * * @export @@ -4248,6 +4359,43 @@ export interface UsageByUserDto { */ 'videos': number; } +/** + * + * @export + * @interface UserDto + */ +export interface UserDto { + /** + * + * @type {string} + * @memberof UserDto + */ + 'email': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'firstName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'lastName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'profileImagePath': string; +} /** * * @export @@ -4831,6 +4979,435 @@ export class APIKeyApi extends BaseAPI { } +/** + * ActivityApi - axios parameter creator + * @export + */ +export const ActivityApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity: async (activityCreateDto: ActivityCreateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'activityCreateDto' is not null or undefined + assertParamExists('createActivity', 'activityCreateDto', activityCreateDto) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(activityCreateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteActivity', 'id', id) + const localVarPath = `/activity/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivities', 'albumId', albumId) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics: async (albumId: string, assetId?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivityStatistics', 'albumId', albumId) + const localVarPath = `/activity/statistics`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ActivityApi - functional programming interface + * @export + */ +export const ActivityApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ActivityApiAxiosParamCreator(configuration) + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createActivity(activityCreateDto: ActivityCreateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createActivity(activityCreateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteActivity(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteActivity(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivities(albumId: string, assetId?: string, type?: ReactionType, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivityStatistics(albumId: string, assetId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivityStatistics(albumId, assetId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * ActivityApi - factory interface + * @export + */ +export const ActivityApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ActivityApiFp(configuration) + return { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createActivity(requestParameters.activityCreateDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteActivity(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createActivity operation in ActivityApi. + * @export + * @interface ActivityApiCreateActivityRequest + */ +export interface ActivityApiCreateActivityRequest { + /** + * + * @type {ActivityCreateDto} + * @memberof ActivityApiCreateActivity + */ + readonly activityCreateDto: ActivityCreateDto +} + +/** + * Request parameters for deleteActivity operation in ActivityApi. + * @export + * @interface ActivityApiDeleteActivityRequest + */ +export interface ActivityApiDeleteActivityRequest { + /** + * + * @type {string} + * @memberof ActivityApiDeleteActivity + */ + readonly id: string +} + +/** + * Request parameters for getActivities operation in ActivityApi. + * @export + * @interface ActivityApiGetActivitiesRequest + */ +export interface ActivityApiGetActivitiesRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly assetId?: string + + /** + * + * @type {ReactionType} + * @memberof ActivityApiGetActivities + */ + readonly type?: ReactionType +} + +/** + * Request parameters for getActivityStatistics operation in ActivityApi. + * @export + * @interface ActivityApiGetActivityStatisticsRequest + */ +export interface ActivityApiGetActivityStatisticsRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly assetId?: string +} + +/** + * ActivityApi - object-oriented interface + * @export + * @class ActivityApi + * @extends {BaseAPI} + */ +export class ActivityApi extends BaseAPI { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).createActivity(requestParameters.activityCreateDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).deleteActivity(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * AlbumApi - axios parameter creator * @export diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index ddc165c188..2333a55f0b 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -5,7 +5,7 @@ import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { locale } from '$lib/stores/preferences.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; - import type { AlbumResponseDto, SharedLinkResponseDto } from '@api'; + import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@api'; import { onDestroy, onMount } from 'svelte'; import { dateFormats } from '../../constants'; import { createAssetInteractionStore } from '../../stores/asset-interaction.store'; @@ -22,6 +22,7 @@ import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js'; export let sharedLink: SharedLinkResponseDto; + export let user: UserResponseDto | undefined = undefined; const album = sharedLink.album as AlbumResponseDto; @@ -138,7 +139,7 @@
- +

+ import { createEventDispatcher } from 'svelte'; + import UserAvatar from '../shared-components/user-avatar.svelte'; + import { mdiClose, mdiHeart, mdiSend, mdiDotsVertical } from '@mdi/js'; + import Icon from '$lib/components/elements/icon.svelte'; + import { ActivityResponseDto, api, AssetTypeEnum, ReactionType, type UserResponseDto } from '@api'; + import { handleError } from '$lib/utils/handle-error'; + import { isTenMinutesApart } from '$lib/utils/timesince'; + import { clickOutside } from '$lib/utils/click-outside'; + import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; + import LoadingSpinner from '../shared-components/loading-spinner.svelte'; + import { NotificationType, notificationController } from '../shared-components/notification/notification'; + import { getAssetType } from '$lib/utils/asset-utils'; + import * as luxon from 'luxon'; + + const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; + + const timeSince = (dateTime: luxon.DateTime) => { + const diff = dateTime.diffNow().shiftTo(...units); + const unit = units.find((unit) => diff.get(unit) !== 0) || 'second'; + + const relativeFormatter = new Intl.RelativeTimeFormat('en', { + numeric: 'auto', + }); + return relativeFormatter.format(Math.trunc(diff.as(unit)), unit); + }; + + export let reactions: ActivityResponseDto[]; + export let user: UserResponseDto; + export let assetId: string; + export let albumId: string; + export let assetType: AssetTypeEnum; + export let albumOwnerId: string; + + let textArea: HTMLTextAreaElement; + let innerHeight: number; + let activityHeight: number; + let chatHeight: number; + let divHeight: number; + let previousAssetId: string | null; + let message = ''; + let isSendingMessage = false; + + const dispatch = createEventDispatcher(); + + $: showDeleteReaction = Array(reactions.length).fill(false); + $: { + if (innerHeight && activityHeight) { + divHeight = innerHeight - activityHeight; + } + } + + $: { + if (previousAssetId != assetId) { + getReactions(); + previousAssetId = assetId; + } + } + + const getReactions = async () => { + try { + const { data } = await api.activityApi.getActivities({ assetId, albumId }); + reactions = data; + } catch (error) { + handleError(error, 'Error when fetching reactions'); + } + }; + + const handleEnter = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSendComment(); + return; + } + }; + + const autoGrow = () => { + textArea.style.height = '5px'; + textArea.style.height = textArea.scrollHeight + 'px'; + }; + + const timeOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + } as Intl.DateTimeFormatOptions; + + const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => { + try { + await api.activityApi.deleteActivity({ id: reaction.id }); + reactions.splice(index, 1); + showDeleteReaction.splice(index, 1); + reactions = reactions; + if (reaction.type === 'like' && reaction.user.id === user.id) { + dispatch('deleteLike'); + } else { + dispatch('deleteComment'); + } + notificationController.show({ + message: `${reaction.type} deleted`, + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, `Can't remove ${reaction.type}`); + } + }; + + const handleSendComment = async () => { + if (!message) { + return; + } + const timeout = setTimeout(() => (isSendingMessage = true), 100); + try { + const { data } = await api.activityApi.createActivity({ + activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message }, + }); + reactions.push(data); + textArea.style.height = '18px'; + message = ''; + dispatch('addComment'); + // Re-render the activity feed + reactions = reactions; + } catch (error) { + handleError(error, "Can't add your comment"); + } finally { + clearTimeout(timeout); + } + isSendingMessage = false; + }; + + const showOptionsMenu = (index: number) => { + showDeleteReaction[index] = !showDeleteReaction[index]; + }; + + +

+
+
+
+ + +

Activity

+
+
+ {#if innerHeight} +
+ {#each reactions as reaction, index (reaction.id)} + {#if reaction.type === 'comment'} +
+
+ +
+ +
{reaction.comment}
+ {#if reaction.user.id === user.id || albumOwnerId === user.id} +
+ +
+ {/if} +
+ {#if showDeleteReaction[index]} + + {/if} +
+
+ {#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} +
+ {timeSince(luxon.DateTime.fromISO(reaction.createdAt))} +
+ {/if} + {:else if reaction.type === 'like'} +
+
+
+ +
+ {`${reaction.user.firstName} ${reaction.user.lastName} liked this ${getAssetType( + assetType, + ).toLowerCase()}`} +
+ {#if reaction.user.id === user.id || albumOwnerId === user.id} +
+ +
+ {/if} +
+ {#if showDeleteReaction[index]} + + {/if} +
+
+ {#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} +
+ {timeSince(luxon.DateTime.fromISO(reaction.createdAt))} +
+ {/if} +
+ {/if} + {/each} +
+ {/if} +
+ +
+
+
+
+ +
+
handleSendComment()}> +
+