mirror of https://github.com/immich-app/immich.git
feat(web,server): activity (#4682)
* feat: activity * regenerate api * fix: make asset owner unable to delete comment * fix: merge * fix: tests * feat: use textarea instead of input * fix: do actions only if the album is shared * fix: placeholder opacity * fix(web): improve messages UI * fix(web): improve input message UI * pr feedback * fix: tests * pr feedback * pr feedback * pr feedback * fix permissions * regenerate api * pr feedback * pr feedback * multiple improvements on web * fix: ui colors * WIP * chore: open api * pr feedback * fix: add comment * chore: clean up * pr feedback * refactor: endpoints * chore: open api * fix: filter by type * fix: e2e * feat: e2e remove own comment * fix: web tests * remove console.log * chore: cleanup * fix: ui tweaks * pr feedback * fix web test * fix: unit tests * chore: remove unused code * revert useless changes * fix: grouping messages * fix: remove nullable on updatedAt * fix: text overflow * styling --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>pull/4766/head
parent
68f6446718
commit
ce5966c23d
@ -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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('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<ActivityResponseDto> getActivities(albumId, assetId, type)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('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>**](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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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<Response> createActivityWithHttpInfo(ActivityCreateDto activityCreateDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/activity';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = activityCreateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [ActivityCreateDto] activityCreateDto (required):
|
||||
Future<ActivityResponseDto?> 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<Response> deleteActivityWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/activity/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<void> 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<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/activity';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
queryParams.addAll(_queryParams('', 'albumId', albumId));
|
||||
if (assetId != null) {
|
||||
queryParams.addAll(_queryParams('', 'assetId', assetId));
|
||||
}
|
||||
if (type != null) {
|
||||
queryParams.addAll(_queryParams('', 'type', type));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] albumId (required):
|
||||
///
|
||||
/// * [String] assetId:
|
||||
///
|
||||
/// * [ReactionType] type:
|
||||
Future<List<ActivityResponseDto>?> 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<ActivityResponseDto>') as List)
|
||||
.cast<ActivityResponseDto>()
|
||||
.toList();
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /activity/statistics' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] albumId (required):
|
||||
///
|
||||
/// * [String] assetId:
|
||||
Future<Response> getActivityStatisticsWithHttpInfo(String albumId, { String? assetId, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/activity/statistics';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
queryParams.addAll(_queryParams('', 'albumId', albumId));
|
||||
if (assetId != null) {
|
||||
queryParams.addAll(_queryParams('', 'assetId', assetId));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] albumId (required):
|
||||
///
|
||||
/// * [String] assetId:
|
||||
Future<ActivityStatisticsResponseDto?> 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;
|
||||
}
|
||||
}
|
||||
@ -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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return ActivityCreateDto(
|
||||
albumId: mapValueOfType<String>(json, r'albumId')!,
|
||||
assetId: mapValueOfType<String>(json, r'assetId'),
|
||||
comment: mapValueOfType<String>(json, r'comment'),
|
||||
type: ReactionType.fromJson(json[r'type'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ActivityCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ActivityCreateDto>[];
|
||||
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<String, ActivityCreateDto> mapFromJson(dynamic json) {
|
||||
final map = <String, ActivityCreateDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // 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<String, List<ActivityCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ActivityCreateDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
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 = <String>{
|
||||
'albumId',
|
||||
'type',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return ActivityResponseDto(
|
||||
assetId: mapValueOfType<String>(json, r'assetId'),
|
||||
comment: mapValueOfType<String>(json, r'comment'),
|
||||
createdAt: mapDateTime(json, r'createdAt', '')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
type: ActivityResponseDtoTypeEnum.fromJson(json[r'type'])!,
|
||||
user: UserDto.fromJson(json[r'user'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ActivityResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ActivityResponseDto>[];
|
||||
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<String, ActivityResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, ActivityResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // 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<String, List<ActivityResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ActivityResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
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 = <String>{
|
||||
'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 = <ActivityResponseDtoTypeEnum>[
|
||||
comment,
|
||||
like,
|
||||
];
|
||||
|
||||
static ActivityResponseDtoTypeEnum? fromJson(dynamic value) => ActivityResponseDtoTypeEnumTypeTransformer().decode(value);
|
||||
|
||||
static List<ActivityResponseDtoTypeEnum>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ActivityResponseDtoTypeEnum>[];
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return ActivityStatisticsResponseDto(
|
||||
comments: mapValueOfType<int>(json, r'comments')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ActivityStatisticsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ActivityStatisticsResponseDto>[];
|
||||
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<String, ActivityStatisticsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, ActivityStatisticsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // 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<String, List<ActivityStatisticsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ActivityStatisticsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
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 = <String>{
|
||||
'comments',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 = <ReactionType>[
|
||||
comment,
|
||||
like,
|
||||
];
|
||||
|
||||
static ReactionType? fromJson(dynamic value) => ReactionTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<ReactionType>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ReactionType>[];
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return UserDto(
|
||||
email: mapValueOfType<String>(json, r'email')!,
|
||||
firstName: mapValueOfType<String>(json, r'firstName')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
lastName: mapValueOfType<String>(json, r'lastName')!,
|
||||
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UserDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UserDto>[];
|
||||
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<String, UserDto> mapFromJson(dynamic json) {
|
||||
final map = <String, UserDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // 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<String, List<UserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UserDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
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 = <String>{
|
||||
'email',
|
||||
'firstName',
|
||||
'id',
|
||||
'lastName',
|
||||
'profileImagePath',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<ActivityResponseDto> createActivity(ActivityCreateDto activityCreateDto) async
|
||||
test('test createActivity', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future deleteActivity(String id) async
|
||||
test('test deleteActivity', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<List<ActivityResponseDto>> getActivities(String albumId, { String assetId, ReactionType type }) async
|
||||
test('test getActivities', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<ActivityStatisticsResponseDto> getActivityStatistics(String albumId, { String assetId }) async
|
||||
test('test getActivityStatistics', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -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', () {
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -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<T> = { 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),
|
||||
};
|
||||
}
|
||||
@ -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<ActivityResponseDto[]> {
|
||||
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<ActivityStatisticsResponseDto> {
|
||||
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<MaybeDuplicate<ActivityResponseDto>> {
|
||||
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<void> {
|
||||
await this.access.requirePermission(authUser, Permission.ACTIVITY_DELETE, id);
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
}
|
||||
@ -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<IActivityRepository>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './activity.dto';
|
||||
export * from './activity.service';
|
||||
@ -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<ActivityEntity[]>;
|
||||
create(activity: Partial<ActivityEntity>): Promise<ActivityEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
getStatistics(assetId: string | undefined, albumId: string): Promise<number>;
|
||||
}
|
||||
@ -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<ResponseDto[]> {
|
||||
return this.service.getAll(authUser, dto);
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
getActivityStatistics(@AuthUser() authUser: AuthUserDto, @Query() dto: ActivityDto): Promise<StatsResponseDto> {
|
||||
return this.service.getStatistics(authUser, dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createActivity(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Body() dto: CreateDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<ResponseDto> {
|
||||
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<void> {
|
||||
return this.service.delete(authUser, id);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddActivity1698693294632 implements MigrationInterface {
|
||||
name = 'AddActivity1698693294632'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<ActivityEntity>) {}
|
||||
|
||||
search(options: ActivitySearch): Promise<ActivityEntity[]> {
|
||||
const { userId, assetId, albumId, isLiked } = options;
|
||||
return this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
assetId,
|
||||
albumId,
|
||||
isLiked,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
order: {
|
||||
createdAt: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
create(entity: Partial<ActivityEntity>): Promise<ActivityEntity> {
|
||||
return this.save(entity);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
|
||||
getStatistics(assetId: string, albumId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { assetId, albumId, isLiked: false },
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async save(entity: Partial<ActivityEntity>) {
|
||||
const { id } = await this.repository.save(entity);
|
||||
return this.repository.findOneOrFail({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<ActivityEntity>({
|
||||
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<ActivityEntity>({
|
||||
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(),
|
||||
}),
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { IActivityRepository } from '@app/domain';
|
||||
|
||||
export const newActivityRepositoryMock = (): jest.Mocked<IActivityRepository> => {
|
||||
return {
|
||||
search: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getStatistics: jest.fn(),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
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];
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}>
|
||||
<div class="dark:bg-immich-dark-bg dark:text-immich-dark-fg w-full h-full">
|
||||
<div
|
||||
class="flex w-full h-fit dark:bg-immich-dark-bg dark:text-immich-dark-fg p-2 bg-white"
|
||||
bind:clientHeight={activityHeight}
|
||||
>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<button
|
||||
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
on:click={() => dispatch('close')}
|
||||
>
|
||||
<Icon path={mdiClose} size="24" />
|
||||
</button>
|
||||
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Activity</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if innerHeight}
|
||||
<div
|
||||
class="overflow-y-auto immich-scrollbar relative w-full"
|
||||
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
|
||||
>
|
||||
{#each reactions as reaction, index (reaction.id)}
|
||||
{#if reaction.type === 'comment'}
|
||||
<div class="flex dark:bg-gray-800 bg-gray-200 p-3 mx-2 mt-3 rounded-lg gap-4 justify-start">
|
||||
<div>
|
||||
<UserAvatar user={reaction.user} size="sm" />
|
||||
</div>
|
||||
|
||||
<div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div>
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
<div class="flex items-start w-fit pt-[5px]" title="Delete comment">
|
||||
<button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}>
|
||||
<Icon path={mdiDotsVertical} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
{#if showDeleteReaction[index]}
|
||||
<button
|
||||
class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-2 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-300 transition-colors"
|
||||
use:clickOutside
|
||||
on:outclick={() => (showDeleteReaction[index] = false)}
|
||||
on:click={() => handleDeleteReaction(reaction, index)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
|
||||
<div
|
||||
class=" px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
||||
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
|
||||
>
|
||||
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if reaction.type === 'like'}
|
||||
<div class="relative">
|
||||
<div class="flex p-2 mx-2 mt-2 rounded-full gap-2 items-center text-sm">
|
||||
<div class="text-red-600"><Icon path={mdiHeart} size={20} /></div>
|
||||
|
||||
<div
|
||||
class="w-full"
|
||||
title={`${reaction.user.firstName} ${reaction.user.lastName} (${reaction.user.email})`}
|
||||
>
|
||||
{`${reaction.user.firstName} ${reaction.user.lastName} liked this ${getAssetType(
|
||||
assetType,
|
||||
).toLowerCase()}`}
|
||||
</div>
|
||||
{#if reaction.user.id === user.id || albumOwnerId === user.id}
|
||||
<div class="flex items-start w-fit" title="Delete like">
|
||||
<button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}>
|
||||
<Icon path={mdiDotsVertical} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
{#if showDeleteReaction[index]}
|
||||
<button
|
||||
class="absolute top-2 right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 p-3 text-left text-sm font-medium text-immich-fg hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg"
|
||||
use:clickOutside
|
||||
on:outclick={() => (showDeleteReaction[index] = false)}
|
||||
on:click={() => handleDeleteReaction(reaction, index)}
|
||||
>
|
||||
Delete Like
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
|
||||
<div
|
||||
class=" px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
||||
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}
|
||||
>
|
||||
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="absolute w-full bottom-0">
|
||||
<div class="flex items-center justify-center p-2 mr-2" bind:clientHeight={chatHeight}>
|
||||
<div class="flex p-2 gap-4 h-fit bg-gray-200 text-immich-dark-gray rounded-3xl w-full">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" showTitle={false} />
|
||||
</div>
|
||||
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<textarea
|
||||
bind:this={textArea}
|
||||
bind:value={message}
|
||||
placeholder="Say something"
|
||||
on:input={autoGrow}
|
||||
on:keypress={handleEnter}
|
||||
class="h-[18px] w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
/>
|
||||
</div>
|
||||
{#if isSendingMessage}
|
||||
<div class="flex items-end place-items-center pb-2 ml-0">
|
||||
<div class="flex w-full place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</div>
|
||||
{:else if message}
|
||||
<div class="flex items-end w-fit ml-0 text-immich-primary dark:text-white">
|
||||
<CircleIconButton size="15" icon={mdiSend} />
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
::placeholder {
|
||||
color: rgb(60, 60, 60);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
::-ms-input-placeholder {
|
||||
/* Edge 12 -18 */
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,9 @@
|
||||
export const isTenMinutesApart = (date1: string, date2: string): boolean => {
|
||||
if (!date1 || !date2) {
|
||||
return false;
|
||||
}
|
||||
const diffInMilliseconds = Math.abs(new Date(date1).getTime() - new Date(date2).getTime());
|
||||
const minutesDifference = diffInMilliseconds / (1000 * 60);
|
||||
|
||||
return minutesDifference >= 10;
|
||||
};
|
||||
Loading…
Reference in New Issue