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 9e5462b084..d11037497e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -77,6 +77,10 @@ Class | Method | HTTP request | Description *APIKeyApi* | [**getKey**](doc//APIKeyApi.md#getkey) | **GET** /api-key/{id} | *APIKeyApi* | [**getKeys**](doc//APIKeyApi.md#getkeys) | **GET** /api-key | *APIKeyApi* | [**updateKey**](doc//APIKeyApi.md#updatekey) | **PUT** /api-key/{id} | +*ActivityApi* | [**createActivity**](doc//ActivityApi.md#createactivity) | **POST** /activity | +*ActivityApi* | [**deleteActivity**](doc//ActivityApi.md#deleteactivity) | **DELETE** /activity/{id} | +*ActivityApi* | [**getActivities**](doc//ActivityApi.md#getactivities) | **GET** /activity | +*ActivityApi* | [**getActivityStatistics**](doc//ActivityApi.md#getactivitystatistics) | **GET** /activity/statistics | *AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets | *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | @@ -204,6 +208,9 @@ Class | Method | HTTP request | Description - [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md) - [APIKeyResponseDto](doc//APIKeyResponseDto.md) - [APIKeyUpdateDto](doc//APIKeyUpdateDto.md) + - [ActivityCreateDto](doc//ActivityCreateDto.md) + - [ActivityResponseDto](doc//ActivityResponseDto.md) + - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) - [AddUsersDto](doc//AddUsersDto.md) - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md) - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) @@ -284,6 +291,7 @@ Class | Method | HTTP request | Description - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [ReactionType](doc//ReactionType.md) - [RecognitionConfig](doc//RecognitionConfig.md) - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) @@ -339,6 +347,7 @@ Class | Method | HTTP request | Description - [UpdateTagDto](doc//UpdateTagDto.md) - [UpdateUserDto](doc//UpdateUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) + - [UserDto](doc//UserDto.md) - [UserResponseDto](doc//UserResponseDto.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) - [VideoCodec](doc//VideoCodec.md) diff --git a/mobile/openapi/doc/ActivityApi.md b/mobile/openapi/doc/ActivityApi.md new file mode 100644 index 0000000000..8ae91efc29 --- /dev/null +++ b/mobile/openapi/doc/ActivityApi.md @@ -0,0 +1,242 @@ +# openapi.api.ActivityApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**createActivity**](ActivityApi.md#createactivity) | **POST** /activity | +[**deleteActivity**](ActivityApi.md#deleteactivity) | **DELETE** /activity/{id} | +[**getActivities**](ActivityApi.md#getactivities) | **GET** /activity | +[**getActivityStatistics**](ActivityApi.md#getactivitystatistics) | **GET** /activity/statistics | + + +# **createActivity** +> ActivityResponseDto createActivity(activityCreateDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final activityCreateDto = ActivityCreateDto(); // ActivityCreateDto | + +try { + final result = api_instance.createActivity(activityCreateDto); + print(result); +} catch (e) { + print('Exception when calling ActivityApi->createActivity: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **activityCreateDto** | [**ActivityCreateDto**](ActivityCreateDto.md)| | + +### Return type + +[**ActivityResponseDto**](ActivityResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **deleteActivity** +> deleteActivity(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api_instance.deleteActivity(id); +} catch (e) { + print('Exception when calling ActivityApi->deleteActivity: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **getActivities** +> List getActivities(albumId, assetId, type) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final type = ; // ReactionType | + +try { + final result = api_instance.getActivities(albumId, assetId, type); + print(result); +} catch (e) { + print('Exception when calling ActivityApi->getActivities: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **albumId** | **String**| | + **assetId** | **String**| | [optional] + **type** | [**ReactionType**](.md)| | [optional] + +### Return type + +[**List**](ActivityResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **getActivityStatistics** +> ActivityStatisticsResponseDto getActivityStatistics(albumId, assetId) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.getActivityStatistics(albumId, assetId); + print(result); +} catch (e) { + print('Exception when calling ActivityApi->getActivityStatistics: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **albumId** | **String**| | + **assetId** | **String**| | [optional] + +### Return type + +[**ActivityStatisticsResponseDto**](ActivityStatisticsResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/doc/ActivityCreateDto.md b/mobile/openapi/doc/ActivityCreateDto.md new file mode 100644 index 0000000000..32cfdb5c71 --- /dev/null +++ b/mobile/openapi/doc/ActivityCreateDto.md @@ -0,0 +1,18 @@ +# openapi.model.ActivityCreateDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**albumId** | **String** | | +**assetId** | **String** | | [optional] +**comment** | **String** | | [optional] +**type** | [**ReactionType**](ReactionType.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ActivityResponseDto.md b/mobile/openapi/doc/ActivityResponseDto.md new file mode 100644 index 0000000000..3b8589b61e --- /dev/null +++ b/mobile/openapi/doc/ActivityResponseDto.md @@ -0,0 +1,20 @@ +# openapi.model.ActivityResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**assetId** | **String** | | +**comment** | **String** | | [optional] +**createdAt** | [**DateTime**](DateTime.md) | | +**id** | **String** | | +**type** | **String** | | +**user** | [**UserDto**](UserDto.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ActivityStatisticsResponseDto.md b/mobile/openapi/doc/ActivityStatisticsResponseDto.md new file mode 100644 index 0000000000..00583aea97 --- /dev/null +++ b/mobile/openapi/doc/ActivityStatisticsResponseDto.md @@ -0,0 +1,15 @@ +# openapi.model.ActivityStatisticsResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**comments** | **int** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ReactionType.md b/mobile/openapi/doc/ReactionType.md new file mode 100644 index 0000000000..0cc41e23a9 --- /dev/null +++ b/mobile/openapi/doc/ReactionType.md @@ -0,0 +1,14 @@ +# openapi.model.ReactionType + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/UserDto.md b/mobile/openapi/doc/UserDto.md new file mode 100644 index 0000000000..617ceb9d3b --- /dev/null +++ b/mobile/openapi/doc/UserDto.md @@ -0,0 +1,19 @@ +# openapi.model.UserDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**email** | **String** | | +**firstName** | **String** | | +**id** | **String** | | +**lastName** | **String** | | +**profileImagePath** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d72aafe58b..2bd4370103 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -29,6 +29,7 @@ part 'auth/http_basic_auth.dart'; part 'auth/http_bearer_auth.dart'; part 'api/api_key_api.dart'; +part 'api/activity_api.dart'; part 'api/album_api.dart'; part 'api/asset_api.dart'; part 'api/audit_api.dart'; @@ -49,6 +50,9 @@ part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; part 'model/api_key_response_dto.dart'; part 'model/api_key_update_dto.dart'; +part 'model/activity_create_dto.dart'; +part 'model/activity_response_dto.dart'; +part 'model/activity_statistics_response_dto.dart'; part 'model/add_users_dto.dart'; part 'model/admin_signup_response_dto.dart'; part 'model/album_count_response_dto.dart'; @@ -129,6 +133,7 @@ part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/queue_status_dto.dart'; +part 'model/reaction_type.dart'; part 'model/recognition_config.dart'; part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; @@ -184,6 +189,7 @@ part 'model/update_stack_parent_dto.dart'; part 'model/update_tag_dto.dart'; part 'model/update_user_dto.dart'; part 'model/usage_by_user_dto.dart'; +part 'model/user_dto.dart'; part 'model/user_response_dto.dart'; part 'model/validate_access_token_response_dto.dart'; part 'model/video_codec.dart'; diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart new file mode 100644 index 0000000000..fa04c68b5a --- /dev/null +++ b/mobile/openapi/lib/api/activity_api.dart @@ -0,0 +1,227 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ActivityApi { + ActivityApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /activity' operation and returns the [Response]. + /// Parameters: + /// + /// * [ActivityCreateDto] activityCreateDto (required): + Future createActivityWithHttpInfo(ActivityCreateDto activityCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/activity'; + + // ignore: prefer_final_locals + Object? postBody = activityCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [ActivityCreateDto] activityCreateDto (required): + Future createActivity(ActivityCreateDto activityCreateDto,) async { + final response = await createActivityWithHttpInfo(activityCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ActivityResponseDto',) as ActivityResponseDto; + + } + return null; + } + + /// Performs an HTTP 'DELETE /activity/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteActivityWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/activity/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteActivity(String id,) async { + final response = await deleteActivityWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /activity' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + /// + /// * [ReactionType] type: + Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async { + // ignore: prefer_const_declarations + final path = r'/activity'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'albumId', albumId)); + if (assetId != null) { + queryParams.addAll(_queryParams('', 'assetId', assetId)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + /// + /// * [ReactionType] type: + Future?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async { + final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + + /// Performs an HTTP 'GET /activity/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + Future getActivityStatisticsWithHttpInfo(String albumId, { String? assetId, }) async { + // ignore: prefer_const_declarations + final path = r'/activity/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'albumId', albumId)); + if (assetId != null) { + queryParams.addAll(_queryParams('', 'assetId', assetId)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + Future getActivityStatistics(String albumId, { String? assetId, }) async { + final response = await getActivityStatisticsWithHttpInfo(albumId, assetId: assetId, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ActivityStatisticsResponseDto',) as ActivityStatisticsResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a61a6b4a96..76a849d719 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -189,6 +189,12 @@ class ApiClient { return APIKeyResponseDto.fromJson(value); case 'APIKeyUpdateDto': return APIKeyUpdateDto.fromJson(value); + case 'ActivityCreateDto': + return ActivityCreateDto.fromJson(value); + case 'ActivityResponseDto': + return ActivityResponseDto.fromJson(value); + case 'ActivityStatisticsResponseDto': + return ActivityStatisticsResponseDto.fromJson(value); case 'AddUsersDto': return AddUsersDto.fromJson(value); case 'AdminSignupResponseDto': @@ -349,6 +355,8 @@ class ApiClient { return PersonUpdateDto.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'ReactionType': + return ReactionTypeTypeTransformer().decode(value); case 'RecognitionConfig': return RecognitionConfig.fromJson(value); case 'ScanLibraryDto': @@ -459,6 +467,8 @@ class ApiClient { return UpdateUserDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); + case 'UserDto': + return UserDto.fromJson(value); case 'UserResponseDto': return UserResponseDto.fromJson(value); case 'ValidateAccessTokenResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 417a282e09..da870ea99c 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,6 +97,9 @@ String parameterToString(dynamic value) { if (value is PathType) { return PathTypeTypeTransformer().encode(value).toString(); } + if (value is ReactionType) { + return ReactionTypeTypeTransformer().encode(value).toString(); + } if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart new file mode 100644 index 0000000000..89618d403d --- /dev/null +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -0,0 +1,140 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ActivityCreateDto { + /// Returns a new [ActivityCreateDto] instance. + ActivityCreateDto({ + required this.albumId, + this.assetId, + this.comment, + required this.type, + }); + + String albumId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? assetId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? comment; + + ReactionType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is ActivityCreateDto && + other.albumId == albumId && + other.assetId == assetId && + other.comment == comment && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumId.hashCode) + + (assetId == null ? 0 : assetId!.hashCode) + + (comment == null ? 0 : comment!.hashCode) + + (type.hashCode); + + @override + String toString() => 'ActivityCreateDto[albumId=$albumId, assetId=$assetId, comment=$comment, type=$type]'; + + Map toJson() { + final json = {}; + json[r'albumId'] = this.albumId; + if (this.assetId != null) { + json[r'assetId'] = this.assetId; + } else { + // json[r'assetId'] = null; + } + if (this.comment != null) { + json[r'comment'] = this.comment; + } else { + // json[r'comment'] = null; + } + json[r'type'] = this.type; + return json; + } + + /// Returns a new [ActivityCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ActivityCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ActivityCreateDto( + albumId: mapValueOfType(json, r'albumId')!, + assetId: mapValueOfType(json, r'assetId'), + comment: mapValueOfType(json, r'comment'), + type: ReactionType.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ActivityCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ActivityCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ActivityCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumId', + 'type', + }; +} + 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 0000000000..0f4cbd4813 --- /dev/null +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -0,0 +1,219 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ActivityResponseDto { + /// Returns a new [ActivityResponseDto] instance. + ActivityResponseDto({ + required this.assetId, + this.comment, + required this.createdAt, + required this.id, + required this.type, + required this.user, + }); + + String? assetId; + + String? comment; + + DateTime createdAt; + + String id; + + ActivityResponseDtoTypeEnum type; + + UserDto user; + + @override + bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto && + other.assetId == assetId && + other.comment == comment && + other.createdAt == createdAt && + other.id == id && + other.type == type && + other.user == user; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId == null ? 0 : assetId!.hashCode) + + (comment == null ? 0 : comment!.hashCode) + + (createdAt.hashCode) + + (id.hashCode) + + (type.hashCode) + + (user.hashCode); + + @override + String toString() => 'ActivityResponseDto[assetId=$assetId, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]'; + + Map toJson() { + final json = {}; + if (this.assetId != null) { + json[r'assetId'] = this.assetId; + } else { + // json[r'assetId'] = null; + } + if (this.comment != null) { + json[r'comment'] = this.comment; + } else { + // json[r'comment'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'id'] = this.id; + json[r'type'] = this.type; + json[r'user'] = this.user; + return json; + } + + /// Returns a new [ActivityResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ActivityResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ActivityResponseDto( + assetId: mapValueOfType(json, r'assetId'), + comment: mapValueOfType(json, r'comment'), + createdAt: mapDateTime(json, r'createdAt', '')!, + id: mapValueOfType(json, r'id')!, + type: ActivityResponseDtoTypeEnum.fromJson(json[r'type'])!, + user: UserDto.fromJson(json[r'user'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ActivityResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ActivityResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ActivityResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'createdAt', + 'id', + 'type', + 'user', + }; +} + + +class ActivityResponseDtoTypeEnum { + /// Instantiate a new enum with the provided [value]. + const ActivityResponseDtoTypeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const comment = ActivityResponseDtoTypeEnum._(r'comment'); + static const like = ActivityResponseDtoTypeEnum._(r'like'); + + /// List of all possible values in this [enum][ActivityResponseDtoTypeEnum]. + static const values = [ + comment, + like, + ]; + + static ActivityResponseDtoTypeEnum? fromJson(dynamic value) => ActivityResponseDtoTypeEnumTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityResponseDtoTypeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ActivityResponseDtoTypeEnum] to String, +/// and [decode] dynamic data back to [ActivityResponseDtoTypeEnum]. +class ActivityResponseDtoTypeEnumTypeTransformer { + factory ActivityResponseDtoTypeEnumTypeTransformer() => _instance ??= const ActivityResponseDtoTypeEnumTypeTransformer._(); + + const ActivityResponseDtoTypeEnumTypeTransformer._(); + + String encode(ActivityResponseDtoTypeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a ActivityResponseDtoTypeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ActivityResponseDtoTypeEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'comment': return ActivityResponseDtoTypeEnum.comment; + case r'like': return ActivityResponseDtoTypeEnum.like; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ActivityResponseDtoTypeEnumTypeTransformer] instance. + static ActivityResponseDtoTypeEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart new file mode 100644 index 0000000000..1b6af12291 --- /dev/null +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ActivityStatisticsResponseDto { + /// Returns a new [ActivityStatisticsResponseDto] instance. + ActivityStatisticsResponseDto({ + required this.comments, + }); + + int comments; + + @override + bool operator ==(Object other) => identical(this, other) || other is ActivityStatisticsResponseDto && + other.comments == comments; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (comments.hashCode); + + @override + String toString() => 'ActivityStatisticsResponseDto[comments=$comments]'; + + Map toJson() { + final json = {}; + json[r'comments'] = this.comments; + return json; + } + + /// Returns a new [ActivityStatisticsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ActivityStatisticsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ActivityStatisticsResponseDto( + comments: mapValueOfType(json, r'comments')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityStatisticsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ActivityStatisticsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ActivityStatisticsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ActivityStatisticsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'comments', + }; +} + diff --git a/mobile/openapi/lib/model/reaction_type.dart b/mobile/openapi/lib/model/reaction_type.dart new file mode 100644 index 0000000000..dbe6c11d29 --- /dev/null +++ b/mobile/openapi/lib/model/reaction_type.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ReactionType { + /// Instantiate a new enum with the provided [value]. + const ReactionType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const comment = ReactionType._(r'comment'); + static const like = ReactionType._(r'like'); + + /// List of all possible values in this [enum][ReactionType]. + static const values = [ + comment, + like, + ]; + + static ReactionType? fromJson(dynamic value) => ReactionTypeTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ReactionType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ReactionType] to String, +/// and [decode] dynamic data back to [ReactionType]. +class ReactionTypeTypeTransformer { + factory ReactionTypeTypeTransformer() => _instance ??= const ReactionTypeTypeTransformer._(); + + const ReactionTypeTypeTransformer._(); + + String encode(ReactionType data) => data.value; + + /// Decodes a [dynamic value][data] to a ReactionType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ReactionType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'comment': return ReactionType.comment; + case r'like': return ReactionType.like; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ReactionTypeTypeTransformer] instance. + static ReactionTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/user_dto.dart b/mobile/openapi/lib/model/user_dto.dart new file mode 100644 index 0000000000..e96588cf53 --- /dev/null +++ b/mobile/openapi/lib/model/user_dto.dart @@ -0,0 +1,130 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class UserDto { + /// Returns a new [UserDto] instance. + UserDto({ + required this.email, + required this.firstName, + required this.id, + required this.lastName, + required this.profileImagePath, + }); + + String email; + + String firstName; + + String id; + + String lastName; + + String profileImagePath; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserDto && + other.email == email && + other.firstName == firstName && + other.id == id && + other.lastName == lastName && + other.profileImagePath == profileImagePath; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (email.hashCode) + + (firstName.hashCode) + + (id.hashCode) + + (lastName.hashCode) + + (profileImagePath.hashCode); + + @override + String toString() => 'UserDto[email=$email, firstName=$firstName, id=$id, lastName=$lastName, profileImagePath=$profileImagePath]'; + + Map toJson() { + final json = {}; + json[r'email'] = this.email; + json[r'firstName'] = this.firstName; + json[r'id'] = this.id; + json[r'lastName'] = this.lastName; + json[r'profileImagePath'] = this.profileImagePath; + return json; + } + + /// Returns a new [UserDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UserDto( + email: mapValueOfType(json, r'email')!, + firstName: mapValueOfType(json, r'firstName')!, + id: mapValueOfType(json, r'id')!, + lastName: mapValueOfType(json, r'lastName')!, + profileImagePath: mapValueOfType(json, r'profileImagePath')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UserDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = UserDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = UserDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'email', + 'firstName', + 'id', + 'lastName', + 'profileImagePath', + }; +} + diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart new file mode 100644 index 0000000000..401264c2bb --- /dev/null +++ b/mobile/openapi/test/activity_api_test.dart @@ -0,0 +1,41 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + + +/// tests for ActivityApi +void main() { + // final instance = ActivityApi(); + + group('tests for ActivityApi', () { + //Future createActivity(ActivityCreateDto activityCreateDto) async + test('test createActivity', () async { + // TODO + }); + + //Future deleteActivity(String id) async + test('test deleteActivity', () async { + // TODO + }); + + //Future> getActivities(String albumId, { String assetId, ReactionType type }) async + test('test getActivities', () async { + // TODO + }); + + //Future getActivityStatistics(String albumId, { String assetId }) async + test('test getActivityStatistics', () async { + // TODO + }); + + }); +} 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 0000000000..263f1e27d7 --- /dev/null +++ b/mobile/openapi/test/activity_create_dto_test.dart @@ -0,0 +1,42 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ActivityCreateDto +void main() { + // final instance = ActivityCreateDto(); + + group('test ActivityCreateDto', () { + // String albumId + test('to test the property `albumId`', () async { + // TODO + }); + + // String assetId + test('to test the property `assetId`', () async { + // TODO + }); + + // String comment + test('to test the property `comment`', () async { + // TODO + }); + + // ReactionType type + test('to test the property `type`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/activity_response_dto_test.dart b/mobile/openapi/test/activity_response_dto_test.dart new file mode 100644 index 0000000000..5f70944b60 --- /dev/null +++ b/mobile/openapi/test/activity_response_dto_test.dart @@ -0,0 +1,52 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ActivityResponseDto +void main() { + // final instance = ActivityResponseDto(); + + group('test ActivityResponseDto', () { + // String assetId + test('to test the property `assetId`', () async { + // TODO + }); + + // String comment + test('to test the property `comment`', () async { + // TODO + }); + + // DateTime createdAt + test('to test the property `createdAt`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String type + test('to test the property `type`', () async { + // TODO + }); + + // UserDto user + test('to test the property `user`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/activity_statistics_response_dto_test.dart b/mobile/openapi/test/activity_statistics_response_dto_test.dart new file mode 100644 index 0000000000..05f8bfdd07 --- /dev/null +++ b/mobile/openapi/test/activity_statistics_response_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ActivityStatisticsResponseDto +void main() { + // final instance = ActivityStatisticsResponseDto(); + + group('test ActivityStatisticsResponseDto', () { + // int comments + test('to test the property `comments`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/reaction_type_test.dart b/mobile/openapi/test/reaction_type_test.dart new file mode 100644 index 0000000000..4c0dfc5955 --- /dev/null +++ b/mobile/openapi/test/reaction_type_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ReactionType +void main() { + + group('test ReactionType', () { + + }); + +} diff --git a/mobile/openapi/test/user_dto_test.dart b/mobile/openapi/test/user_dto_test.dart new file mode 100644 index 0000000000..fa1b7da866 --- /dev/null +++ b/mobile/openapi/test/user_dto_test.dart @@ -0,0 +1,47 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for UserDto +void main() { + // final instance = UserDto(); + + group('test UserDto', () { + // String email + test('to test the property `email`', () async { + // TODO + }); + + // String firstName + test('to test the property `firstName`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String lastName + test('to test the property `lastName`', () async { + // TODO + }); + + // String profileImagePath + test('to test the property `profileImagePath`', () async { + // TODO + }); + + + }); + +} 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()}> +
+