diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 45537ddc34415..42a8c3106d00d 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -60,6 +60,7 @@ doc/OAuthApi.md doc/OAuthCallbackDto.md doc/OAuthConfigDto.md doc/OAuthConfigResponseDto.md +doc/PartnerApi.md doc/QueueStatusDto.md doc/RemoveAssetsDto.md doc/SearchAlbumResponseDto.md @@ -111,6 +112,7 @@ lib/api/asset_api.dart lib/api/authentication_api.dart lib/api/job_api.dart lib/api/o_auth_api.dart +lib/api/partner_api.dart lib/api/search_api.dart lib/api/server_info_api.dart lib/api/share_api.dart @@ -271,6 +273,7 @@ test/o_auth_api_test.dart test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart test/o_auth_config_response_dto_test.dart +test/partner_api_test.dart test/queue_status_dto_test.dart test/remove_assets_dto_test.dart test/search_album_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7f4e48a167104..25c6c7a97a370 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -129,6 +129,9 @@ Class | Method | HTTP request | Description *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | +*PartnerApi* | [**createPartner**](doc//PartnerApi.md#createpartner) | **POST** /partner/{id} | +*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner | +*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} | *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | diff --git a/mobile/openapi/doc/GetAssetByTimeBucketDto.md b/mobile/openapi/doc/GetAssetByTimeBucketDto.md index 0ac15e768aae3..b0f7212293fa3 100644 --- a/mobile/openapi/doc/GetAssetByTimeBucketDto.md +++ b/mobile/openapi/doc/GetAssetByTimeBucketDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **timeBucket** | **List** | | [default to const []] +**userId** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/GetAssetCountByTimeBucketDto.md b/mobile/openapi/doc/GetAssetCountByTimeBucketDto.md index 365c9646f6322..e770c3f9bb578 100644 --- a/mobile/openapi/doc/GetAssetCountByTimeBucketDto.md +++ b/mobile/openapi/doc/GetAssetCountByTimeBucketDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **timeGroup** | [**TimeGroupEnum**](TimeGroupEnum.md) | | +**userId** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/PartnerApi.md b/mobile/openapi/doc/PartnerApi.md new file mode 100644 index 0000000000000..937978befb6f0 --- /dev/null +++ b/mobile/openapi/doc/PartnerApi.md @@ -0,0 +1,180 @@ +# openapi.api.PartnerApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**createPartner**](PartnerApi.md#createpartner) | **POST** /partner/{id} | +[**getPartners**](PartnerApi.md#getpartners) | **GET** /partner | +[**removePartner**](PartnerApi.md#removepartner) | **DELETE** /partner/{id} | + + +# **createPartner** +> UserResponseDto createPartner(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = PartnerApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.createPartner(id); + print(result); +} catch (e) { + print('Exception when calling PartnerApi->createPartner: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +[**UserResponseDto**](UserResponseDto.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) + +# **getPartners** +> List getPartners(direction) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = PartnerApi(); +final direction = direction_example; // String | + +try { + final result = api_instance.getPartners(direction); + print(result); +} catch (e) { + print('Exception when calling PartnerApi->getPartners: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **direction** | **String**| | + +### Return type + +[**List**](UserResponseDto.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) + +# **removePartner** +> removePartner(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = PartnerApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api_instance.removePartner(id); +} catch (e) { + print('Exception when calling PartnerApi->removePartner: $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) + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 270cff8eb0042..a7d0cb827cf9f 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -34,6 +34,7 @@ part 'api/asset_api.dart'; part 'api/authentication_api.dart'; part 'api/job_api.dart'; part 'api/o_auth_api.dart'; +part 'api/partner_api.dart'; part 'api/search_api.dart'; part 'api/server_info_api.dart'; part 'api/share_api.dart'; diff --git a/mobile/openapi/lib/api/partner_api.dart b/mobile/openapi/lib/api/partner_api.dart new file mode 100644 index 0000000000000..cf374aafe50dc --- /dev/null +++ b/mobile/openapi/lib/api/partner_api.dart @@ -0,0 +1,158 @@ +// +// 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 PartnerApi { + PartnerApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /partner/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future createPartnerWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/partner/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future createPartner(String id,) async { + final response = await createPartnerWithHttpInfo(id,); + 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), 'UserResponseDto',) as UserResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /partner' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] direction (required): + Future getPartnersWithHttpInfo(String direction,) async { + // ignore: prefer_const_declarations + final path = r'/partner'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'direction', direction)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] direction (required): + Future?> getPartners(String direction,) async { + final response = await getPartnersWithHttpInfo(direction,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + + /// Performs an HTTP 'DELETE /partner/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future removePartnerWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/partner/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future removePartner(String id,) async { + final response = await removePartnerWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/mobile/openapi/lib/model/get_asset_by_time_bucket_dto.dart b/mobile/openapi/lib/model/get_asset_by_time_bucket_dto.dart index 4b487ed870ba3..10210c2e6b8f0 100644 --- a/mobile/openapi/lib/model/get_asset_by_time_bucket_dto.dart +++ b/mobile/openapi/lib/model/get_asset_by_time_bucket_dto.dart @@ -14,25 +14,41 @@ class GetAssetByTimeBucketDto { /// Returns a new [GetAssetByTimeBucketDto] instance. GetAssetByTimeBucketDto({ this.timeBucket = const [], + this.userId, }); List timeBucket; + /// + /// 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? userId; + @override bool operator ==(Object other) => identical(this, other) || other is GetAssetByTimeBucketDto && - other.timeBucket == timeBucket; + other.timeBucket == timeBucket && + other.userId == userId; @override int get hashCode => // ignore: unnecessary_parenthesis - (timeBucket.hashCode); + (timeBucket.hashCode) + + (userId == null ? 0 : userId!.hashCode); @override - String toString() => 'GetAssetByTimeBucketDto[timeBucket=$timeBucket]'; + String toString() => 'GetAssetByTimeBucketDto[timeBucket=$timeBucket, userId=$userId]'; Map toJson() { final json = {}; json[r'timeBucket'] = this.timeBucket; + if (this.userId != null) { + json[r'userId'] = this.userId; + } else { + // json[r'userId'] = null; + } return json; } @@ -58,6 +74,7 @@ class GetAssetByTimeBucketDto { timeBucket: json[r'timeBucket'] is Iterable ? (json[r'timeBucket'] as Iterable).cast().toList(growable: false) : const [], + userId: mapValueOfType(json, r'userId'), ); } return null; diff --git a/mobile/openapi/lib/model/get_asset_count_by_time_bucket_dto.dart b/mobile/openapi/lib/model/get_asset_count_by_time_bucket_dto.dart index 2cd3feda835b1..619c5fe86867a 100644 --- a/mobile/openapi/lib/model/get_asset_count_by_time_bucket_dto.dart +++ b/mobile/openapi/lib/model/get_asset_count_by_time_bucket_dto.dart @@ -14,25 +14,41 @@ class GetAssetCountByTimeBucketDto { /// Returns a new [GetAssetCountByTimeBucketDto] instance. GetAssetCountByTimeBucketDto({ required this.timeGroup, + this.userId, }); TimeGroupEnum timeGroup; + /// + /// 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? userId; + @override bool operator ==(Object other) => identical(this, other) || other is GetAssetCountByTimeBucketDto && - other.timeGroup == timeGroup; + other.timeGroup == timeGroup && + other.userId == userId; @override int get hashCode => // ignore: unnecessary_parenthesis - (timeGroup.hashCode); + (timeGroup.hashCode) + + (userId == null ? 0 : userId!.hashCode); @override - String toString() => 'GetAssetCountByTimeBucketDto[timeGroup=$timeGroup]'; + String toString() => 'GetAssetCountByTimeBucketDto[timeGroup=$timeGroup, userId=$userId]'; Map toJson() { final json = {}; json[r'timeGroup'] = this.timeGroup; + if (this.userId != null) { + json[r'userId'] = this.userId; + } else { + // json[r'userId'] = null; + } return json; } @@ -56,6 +72,7 @@ class GetAssetCountByTimeBucketDto { return GetAssetCountByTimeBucketDto( timeGroup: TimeGroupEnum.fromJson(json[r'timeGroup'])!, + userId: mapValueOfType(json, r'userId'), ); } return null; diff --git a/mobile/openapi/test/get_asset_by_time_bucket_dto_test.dart b/mobile/openapi/test/get_asset_by_time_bucket_dto_test.dart index 33d0d79b90ddc..591f461497b45 100644 --- a/mobile/openapi/test/get_asset_by_time_bucket_dto_test.dart +++ b/mobile/openapi/test/get_asset_by_time_bucket_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // String userId + test('to test the property `userId`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/get_asset_count_by_time_bucket_dto_test.dart b/mobile/openapi/test/get_asset_count_by_time_bucket_dto_test.dart index 83a0cc9199f5d..5fa7c11bec167 100644 --- a/mobile/openapi/test/get_asset_count_by_time_bucket_dto_test.dart +++ b/mobile/openapi/test/get_asset_count_by_time_bucket_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // String userId + test('to test the property `userId`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/partner_api_test.dart b/mobile/openapi/test/partner_api_test.dart new file mode 100644 index 0000000000000..fa5a59d2adcb7 --- /dev/null +++ b/mobile/openapi/test/partner_api_test.dart @@ -0,0 +1,36 @@ +// +// 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 PartnerApi +void main() { + // final instance = PartnerApi(); + + group('tests for PartnerApi', () { + //Future createPartner(String id) async + test('test createPartner', () async { + // TODO + }); + + //Future> getPartners(String direction) async + test('test getPartners', () async { + // TODO + }); + + //Future removePartner(String id) async + test('test removePartner', () async { + // TODO + }); + + }); +} diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index a98349cb3a29a..b9697a1faf193 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -8,7 +8,14 @@ import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { DownloadService } from '../../modules/download/download.service'; import { AlbumRepository, IAlbumRepository } from '../album/album-repository'; -import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain'; +import { + ICryptoRepository, + IJobRepository, + IPartnerRepository, + ISharedLinkRepository, + IStorageRepository, + JobName, +} from '@app/domain'; import { assetEntityStub, authStub, @@ -126,6 +133,7 @@ describe('AssetService', () => { let assetRepositoryMock: jest.Mocked; let albumRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; + let partnerRepositoryMock: jest.Mocked; let sharedLinkRepositoryMock: jest.Mocked; let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; @@ -178,6 +186,7 @@ describe('AssetService', () => { jobMock, cryptoMock, storageMock, + partnerRepositoryMock, ); when(assetRepositoryMock.get) diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 8fe18bb836dbb..dcaae0d956db4 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -32,6 +32,7 @@ import { mapAssetWithoutExif, MapMarkerResponseDto, mapAssetMapMarker, + PartnerCore, } from '@app/domain'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; @@ -56,6 +57,7 @@ import { DownloadService } from '../../modules/download/download.service'; import { DownloadDto } from './dto/download-library.dto'; import { IAlbumRepository } from '../album/album-repository'; import { ShareCore } from '@app/domain'; +import { IPartnerRepository } from '@app/domain'; import { ISharedLinkRepository } from '@app/domain'; import { DownloadFilesDto } from './dto/download-files.dto'; import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; @@ -76,6 +78,7 @@ export class AssetService { readonly logger = new Logger(AssetService.name); private shareCore: ShareCore; private assetCore: AssetCore; + private partnerCore: PartnerCore; constructor( @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @@ -87,9 +90,11 @@ export class AssetService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, ) { this.assetCore = new AssetCore(_assetRepository, jobRepository); this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); + this.partnerCore = new PartnerCore(partnerRepository); } public async uploadFile( @@ -154,7 +159,14 @@ export class AssetService { authUser: AuthUserDto, getAssetByTimeBucketDto: GetAssetByTimeBucketDto, ): Promise { - const assets = await this._assetRepository.getAssetByTimeBucket(authUser.id, getAssetByTimeBucketDto); + if (getAssetByTimeBucketDto.userId) { + await this.checkUserAccess(authUser, getAssetByTimeBucketDto.userId); + } + + const assets = await this._assetRepository.getAssetByTimeBucket( + getAssetByTimeBucketDto.userId || authUser.id, + getAssetByTimeBucketDto, + ); return assets.map((asset) => mapAsset(asset)); } @@ -458,8 +470,12 @@ export class AssetService { authUser: AuthUserDto, getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, ): Promise { + if (getAssetCountByTimeBucketDto.userId !== undefined) { + await this.checkUserAccess(authUser, getAssetCountByTimeBucketDto.userId); + } + const result = await this._assetRepository.getAssetCountByTimeBucket( - authUser.id, + getAssetCountByTimeBucketDto.userId || authUser.id, getAssetCountByTimeBucketDto.timeGroup, ); @@ -492,6 +508,12 @@ export class AssetService { continue; } + // Step 3: Check if any partner owns the asset + const canAccess = await this.partnerCore.hasAssetAccess(assetId, authUser.id); + if (canAccess) { + continue; + } + // Avoid additional checks if ownership is required if (!mustBeOwner) { // Step 2: Check if asset is part of an album shared with me @@ -505,6 +527,13 @@ export class AssetService { } } + private async checkUserAccess(authUser: AuthUserDto, userId: string) { + // Check if userId shares assets with authUser + if (!(await this.partnerCore.get({ sharedById: userId, sharedWithId: authUser.id }))) { + throw new ForbiddenException(); + } + } + checkDownloadAccess(authUser: AuthUserDto) { this.shareCore.checkDownloadAccess(authUser); } diff --git a/server/apps/immich/src/api-v1/asset/dto/get-asset-by-time-bucket.dto.ts b/server/apps/immich/src/api-v1/asset/dto/get-asset-by-time-bucket.dto.ts index 0f98817a40753..6203c3e04ed4a 100644 --- a/server/apps/immich/src/api-v1/asset/dto/get-asset-by-time-bucket.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/get-asset-by-time-bucket.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; export class GetAssetByTimeBucketDto { @IsNotEmpty() @@ -10,4 +10,9 @@ export class GetAssetByTimeBucketDto { example: ['2015-06-01T00:00:00.000Z', '2016-02-01T00:00:00.000Z', '2016-03-01T00:00:00.000Z'], }) timeBucket!: string[]; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ format: 'uuid' }) + userId?: string; } diff --git a/server/apps/immich/src/api-v1/asset/dto/get-asset-count-by-time-bucket.dto.ts b/server/apps/immich/src/api-v1/asset/dto/get-asset-count-by-time-bucket.dto.ts index 8a861734d98fc..58104d5d68393 100644 --- a/server/apps/immich/src/api-v1/asset/dto/get-asset-count-by-time-bucket.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/get-asset-count-by-time-bucket.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; export enum TimeGroupEnum { Day = 'day', @@ -14,4 +14,9 @@ export class GetAssetCountByTimeBucketDto { enumName: 'TimeGroupEnum', }) timeGroup!: TimeGroupEnum; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ format: 'uuid' }) + userId?: string; } diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 335c13fd83153..11b5f15257d91 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -12,9 +12,10 @@ import { AuthController, JobController, OAuthController, + PartnerController, SearchController, ServerInfoController, - ShareController, + SharedLinkController, SystemConfigController, UserController, } from './controllers'; @@ -37,9 +38,10 @@ import { AppCronJobs } from './app.cron-jobs'; AuthController, JobController, OAuthController, + PartnerController, SearchController, ServerInfoController, - ShareController, + SharedLinkController, SystemConfigController, UserController, ], diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index 942c004d92dd9..9846b43a33c68 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -3,8 +3,9 @@ export * from './api-key.controller'; export * from './auth.controller'; export * from './job.controller'; export * from './oauth.controller'; +export * from './partner.controller'; export * from './search.controller'; export * from './server-info.controller'; -export * from './share.controller'; +export * from './shared-link.controller'; export * from './system-config.controller'; export * from './user.controller'; diff --git a/server/apps/immich/src/controllers/partner.controller.ts b/server/apps/immich/src/controllers/partner.controller.ts new file mode 100644 index 0000000000000..e26d16dfecb49 --- /dev/null +++ b/server/apps/immich/src/controllers/partner.controller.ts @@ -0,0 +1,36 @@ +import { PartnerDirection, PartnerService, UserResponseDto } from '@app/domain'; +import { Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiQuery, ApiTags } from '@nestjs/swagger'; +import { AuthUserDto, GetAuthUser } from '../decorators/auth-user.decorator'; +import { Authenticated } from '../decorators/authenticated.decorator'; +import { UseValidation } from '../decorators/use-validation.decorator'; +import { UUIDParamDto } from './dto/uuid-param.dto'; + +@ApiTags('Partner') +@Controller('partner') +@UseValidation() +export class PartnerController { + constructor(private service: PartnerService) {} + + @Authenticated() + @Get() + @ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true }) + getPartners( + @GetAuthUser() authUser: AuthUserDto, + @Query('direction') direction: PartnerDirection, + ): Promise { + return this.service.getAll(authUser, direction); + } + + @Authenticated() + @Post(':id') + createPartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.create(authUser, id); + } + + @Authenticated() + @Delete(':id') + removePartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.remove(authUser, id); + } +} diff --git a/server/apps/immich/src/controllers/share.controller.ts b/server/apps/immich/src/controllers/shared-link.controller.ts similarity index 97% rename from server/apps/immich/src/controllers/share.controller.ts rename to server/apps/immich/src/controllers/shared-link.controller.ts index aef3dad7937e0..9590132a66a5d 100644 --- a/server/apps/immich/src/controllers/share.controller.ts +++ b/server/apps/immich/src/controllers/shared-link.controller.ts @@ -9,7 +9,7 @@ import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('share') @Controller('share') @UseValidation() -export class ShareController { +export class SharedLinkController { constructor(private readonly service: ShareService) {} @Authenticated() diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 12d5f078120cb..20d2f686a4966 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -792,6 +792,129 @@ ] } }, + "/partner": { + "get": { + "operationId": "getPartners", + "parameters": [ + { + "name": "direction", + "required": true, + "in": "query", + "schema": { + "enum": [ + "shared-by", + "shared-with" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserResponseDto" + } + } + } + } + } + }, + "tags": [ + "Partner" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ] + } + }, + "/partner/{id}": { + "post": { + "operationId": "createPartner", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponseDto" + } + } + } + } + }, + "tags": [ + "Partner" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "operationId": "removePartner", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Partner" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ] + } + }, "/search": { "get": { "operationId": "search", @@ -5419,6 +5542,10 @@ "properties": { "timeGroup": { "$ref": "#/components/schemas/TimeGroupEnum" + }, + "userId": { + "type": "string", + "format": "uuid" } }, "required": [ @@ -5504,6 +5631,10 @@ "items": { "type": "string" } + }, + "userId": { + "type": "string", + "format": "uuid" } }, "required": [ diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 7e12849728f29..b2c866f3acae9 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -6,31 +6,33 @@ import { AuthService } from './auth'; import { JobService } from './job'; import { MediaService } from './media'; import { OAuthService } from './oauth'; +import { PartnerService } from './partner'; import { SearchService } from './search'; import { ServerInfoService } from './server-info'; import { ShareService } from './share'; import { SmartInfoService } from './smart-info'; import { StorageService } from './storage'; import { StorageTemplateService } from './storage-template'; -import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config'; import { UserService } from './user'; +import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config'; const providers: Provider[] = [ AlbumService, - AssetService, APIKeyService, + AssetService, AuthService, JobService, MediaService, OAuthService, + PartnerService, + SearchService, ServerInfoService, + ShareService, SmartInfoService, StorageService, StorageTemplateService, SystemConfigService, UserService, - ShareService, - SearchService, { provide: INITIAL_SYSTEM_CONFIG, inject: [SystemConfigService], diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index f3dca00f95991..b5e846fe978c0 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -14,6 +14,7 @@ export * from './metadata'; export * from './oauth'; export * from './search'; export * from './server-info'; +export * from './partner'; export * from './share'; export * from './smart-info'; export * from './storage'; diff --git a/server/libs/domain/src/partner/index.ts b/server/libs/domain/src/partner/index.ts new file mode 100644 index 0000000000000..86006f17feaf0 --- /dev/null +++ b/server/libs/domain/src/partner/index.ts @@ -0,0 +1,3 @@ +export * from './partner.core'; +export * from './partner.repository'; +export * from './partner.service'; diff --git a/server/libs/domain/src/partner/partner.core.ts b/server/libs/domain/src/partner/partner.core.ts new file mode 100644 index 0000000000000..7e51c6fedee27 --- /dev/null +++ b/server/libs/domain/src/partner/partner.core.ts @@ -0,0 +1,33 @@ +import { PartnerEntity } from '@app/infra/entities'; +import { IPartnerRepository, PartnerIds } from './partner.repository'; + +export enum PartnerDirection { + SharedBy = 'shared-by', + SharedWith = 'shared-with', +} + +export class PartnerCore { + constructor(private repository: IPartnerRepository) {} + + async getAll(userId: string, direction: PartnerDirection): Promise { + const partners = await this.repository.getAll(userId); + const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId'; + return partners.filter((partner) => partner[key] === userId); + } + + get(ids: PartnerIds): Promise { + return this.repository.get(ids); + } + + async create(ids: PartnerIds): Promise { + return this.repository.create(ids); + } + + async remove(ids: PartnerIds): Promise { + await this.repository.remove(ids as PartnerEntity); + } + + hasAssetAccess(assetId: string, userId: string): Promise { + return this.repository.hasAssetAccess(assetId, userId); + } +} diff --git a/server/libs/domain/src/partner/partner.repository.ts b/server/libs/domain/src/partner/partner.repository.ts new file mode 100644 index 0000000000000..9e4f78ec120f5 --- /dev/null +++ b/server/libs/domain/src/partner/partner.repository.ts @@ -0,0 +1,16 @@ +import { PartnerEntity } from '@app/infra/entities'; + +export interface PartnerIds { + sharedById: string; + sharedWithId: string; +} + +export const IPartnerRepository = 'IPartnerRepository'; + +export interface IPartnerRepository { + getAll(userId: string): Promise; + get(partner: PartnerIds): Promise; + create(partner: PartnerIds): Promise; + remove(entity: PartnerEntity): Promise; + hasAssetAccess(assetId: string, userId: string): Promise; +} diff --git a/server/libs/domain/src/partner/partner.service.spec.ts b/server/libs/domain/src/partner/partner.service.spec.ts new file mode 100644 index 0000000000000..f29267dbd051d --- /dev/null +++ b/server/libs/domain/src/partner/partner.service.spec.ts @@ -0,0 +1,102 @@ +import { BadRequestException } from '@nestjs/common'; +import { authStub, newPartnerRepositoryMock, partnerStub } from '../../test'; +import { PartnerDirection } from './partner.core'; +import { IPartnerRepository } from './partner.repository'; +import { PartnerService } from './partner.service'; + +const responseDto = { + admin: { + createdAt: '2021-01-01', + deletedAt: undefined, + email: 'admin@test.com', + firstName: 'admin_first_name', + id: 'admin_id', + isAdmin: true, + lastName: 'admin_last_name', + oauthId: '', + profileImagePath: '', + shouldChangePassword: false, + updatedAt: '2021-01-01', + }, + user1: { + createdAt: '2021-01-01', + deletedAt: undefined, + email: 'immich@test.com', + firstName: 'immich_first_name', + id: 'immich_id', + isAdmin: false, + lastName: 'immich_last_name', + oauthId: '', + profileImagePath: '', + shouldChangePassword: false, + updatedAt: '2021-01-01', + }, +}; + +describe(PartnerService.name, () => { + let sut: PartnerService; + let partnerMock: jest.Mocked; + + beforeEach(async () => { + partnerMock = newPartnerRepositoryMock(); + sut = new PartnerService(partnerMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getAll', () => { + it("should return a list of partners with whom I've shared my library", async () => { + partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toEqual([responseDto.admin]); + expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.id); + }); + + it('should return a list of partners who have shared their libraries with me', async () => { + partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toEqual([responseDto.admin]); + expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.id); + }); + }); + + describe('create', () => { + it('should create a new partner', async () => { + partnerMock.get.mockResolvedValue(null); + partnerMock.create.mockResolvedValue(partnerStub.adminToUser1); + + await expect(sut.create(authStub.admin, authStub.user1.id)).resolves.toEqual(responseDto.user1); + + expect(partnerMock.create).toHaveBeenCalledWith({ + sharedById: authStub.admin.id, + sharedWithId: authStub.user1.id, + }); + }); + + it('should throw an error when the partner already exists', async () => { + partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + + await expect(sut.create(authStub.admin, authStub.user1.id)).rejects.toBeInstanceOf(BadRequestException); + + expect(partnerMock.create).not.toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('should remove a partner', async () => { + partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + + await sut.remove(authStub.admin, authStub.user1.id); + + expect(partnerMock.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); + }); + + it('should throw an error when the partner does not exist', async () => { + partnerMock.get.mockResolvedValue(null); + + await expect(sut.remove(authStub.admin, authStub.user1.id)).rejects.toBeInstanceOf(BadRequestException); + + expect(partnerMock.remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/libs/domain/src/partner/partner.service.ts b/server/libs/domain/src/partner/partner.service.ts new file mode 100644 index 0000000000000..0b2c22d84ac9f --- /dev/null +++ b/server/libs/domain/src/partner/partner.service.ts @@ -0,0 +1,45 @@ +import { PartnerEntity } from '@app/infra/entities'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { AuthUserDto } from '../auth'; +import { IPartnerRepository, PartnerCore, PartnerDirection, PartnerIds } from '../partner'; +import { mapUser, UserResponseDto } from '../user'; + +@Injectable() +export class PartnerService { + private partnerCore: PartnerCore; + + constructor(@Inject(IPartnerRepository) partnerRepository: IPartnerRepository) { + this.partnerCore = new PartnerCore(partnerRepository); + } + + async create(authUser: AuthUserDto, sharedWithId: string): Promise { + const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId }; + const exists = await this.partnerCore.get(partnerId); + if (exists) { + throw new BadRequestException(`Partner already exists`); + } + + const partner = await this.partnerCore.create(partnerId); + return this.map(partner, PartnerDirection.SharedBy); + } + + async remove(authUser: AuthUserDto, sharedWithId: string): Promise { + const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId }; + const partner = await this.partnerCore.get(partnerId); + if (!partner) { + throw new BadRequestException('Partner not found'); + } + + await this.partnerCore.remove(partner); + } + + async getAll(authUser: AuthUserDto, direction: PartnerDirection): Promise { + const partners = await this.partnerCore.getAll(authUser.id, direction); + return partners.map((partner) => this.map(partner, direction)); + } + + private map(partner: PartnerEntity, direction: PartnerDirection): UserResponseDto { + // this is opposite to return the non-me user of the "partner" + return mapUser(direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy); + } +} diff --git a/server/libs/domain/src/share/share.core.ts b/server/libs/domain/src/share/share.core.ts index 6d33c2fd5af88..4229999a4e24b 100644 --- a/server/libs/domain/src/share/share.core.ts +++ b/server/libs/domain/src/share/share.core.ts @@ -1,11 +1,5 @@ import { AssetEntity, SharedLinkEntity } from '@app/infra/entities'; -import { - BadRequestException, - ForbiddenException, - InternalServerErrorException, - Logger, - UnauthorizedException, -} from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common'; import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto'; import { CreateSharedLinkDto } from './dto'; @@ -25,24 +19,19 @@ export class ShareCore { } create(userId: string, dto: CreateSharedLinkDto): Promise { - try { - return this.repository.create({ - key: Buffer.from(this.cryptoRepository.randomBytes(50)), - description: dto.description, - userId, - createdAt: new Date().toISOString(), - expiresAt: dto.expiresAt ?? null, - type: dto.type, - assets: dto.assets, - album: dto.album, - allowUpload: dto.allowUpload ?? false, - allowDownload: dto.allowDownload ?? true, - showExif: dto.showExif ?? true, - }); - } catch (error: any) { - this.logger.error(error, error.stack); - throw new InternalServerErrorException('failed to create shared link'); - } + return this.repository.create({ + key: Buffer.from(this.cryptoRepository.randomBytes(50)), + description: dto.description, + userId, + createdAt: new Date().toISOString(), + expiresAt: dto.expiresAt ?? null, + type: dto.type, + assets: dto.assets, + album: dto.album, + allowUpload: dto.allowUpload ?? false, + allowDownload: dto.allowDownload ?? true, + showExif: dto.showExif ?? true, + }); } async save(userId: string, id: string, entity: Partial): Promise { @@ -54,13 +43,13 @@ export class ShareCore { return this.repository.save({ ...entity, userId, id }); } - async remove(userId: string, id: string): Promise { + async remove(userId: string, id: string): Promise { const link = await this.get(userId, id); if (!link) { throw new BadRequestException('Shared link not found'); } - return this.repository.remove(link); + await this.repository.remove(link); } async addAssets(userId: string, id: string, assets: AssetEntity[]) { diff --git a/server/libs/domain/src/share/shared-link.repository.ts b/server/libs/domain/src/share/shared-link.repository.ts index e6d7c40356d3c..1eca524032f96 100644 --- a/server/libs/domain/src/share/shared-link.repository.ts +++ b/server/libs/domain/src/share/shared-link.repository.ts @@ -7,7 +7,7 @@ export interface ISharedLinkRepository { get(userId: string, id: string): Promise; getByKey(key: string): Promise; create(entity: Omit): Promise; - remove(entity: SharedLinkEntity): Promise; + remove(entity: SharedLinkEntity): Promise; save(entity: Partial): Promise; hasAssetAccess(id: string, assetId: string): Promise; } diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index d728d84f8ffeb..6eebc2e474a97 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -3,6 +3,7 @@ import { APIKeyEntity, AssetEntity, AssetType, + PartnerEntity, SharedLinkEntity, SharedLinkType, SystemConfig, @@ -824,3 +825,22 @@ export const probeStub = { }, }), }; + +export const partnerStub = { + adminToUser1: Object.freeze({ + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + sharedById: userEntityStub.admin.id, + sharedBy: userEntityStub.admin, + sharedWith: userEntityStub.user1, + sharedWithId: userEntityStub.user1.id, + }), + user1ToAdmin1: Object.freeze({ + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + sharedBy: userEntityStub.user1, + sharedById: userEntityStub.user1.id, + sharedWithId: userEntityStub.admin.id, + sharedWith: userEntityStub.admin, + }), +}; diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index 19b3e07bebeae..842d291319399 100644 --- a/server/libs/domain/test/index.ts +++ b/server/libs/domain/test/index.ts @@ -7,6 +7,7 @@ export * from './fixtures'; export * from './job.repository.mock'; export * from './machine-learning.repository.mock'; export * from './media.repository.mock'; +export * from './partner.repository.mock'; export * from './search.repository.mock'; export * from './shared-link.repository.mock'; export * from './smart-info.repository.mock'; diff --git a/server/libs/domain/test/partner.repository.mock.ts b/server/libs/domain/test/partner.repository.mock.ts new file mode 100644 index 0000000000000..390790275a397 --- /dev/null +++ b/server/libs/domain/test/partner.repository.mock.ts @@ -0,0 +1,11 @@ +import { IPartnerRepository } from '../src'; + +export const newPartnerRepositoryMock = (): jest.Mocked => { + return { + create: jest.fn(), + remove: jest.fn(), + getAll: jest.fn(), + get: jest.fn(), + hasAssetAccess: jest.fn(), + }; +}; diff --git a/server/libs/infra/src/entities/index.ts b/server/libs/infra/src/entities/index.ts index cb892c4a58d95..8e4a73b147347 100644 --- a/server/libs/infra/src/entities/index.ts +++ b/server/libs/infra/src/entities/index.ts @@ -1,16 +1,18 @@ import { AlbumEntity } from './album.entity'; import { APIKeyEntity } from './api-key.entity'; import { AssetEntity } from './asset.entity'; +import { PartnerEntity } from './partner.entity'; import { SharedLinkEntity } from './shared-link.entity'; import { SmartInfoEntity } from './smart-info.entity'; import { SystemConfigEntity } from './system-config.entity'; -import { UserTokenEntity } from './user-token.entity'; import { UserEntity } from './user.entity'; +import { UserTokenEntity } from './user-token.entity'; export * from './album.entity'; export * from './api-key.entity'; export * from './asset.entity'; export * from './exif.entity'; +export * from './partner.entity'; export * from './shared-link.entity'; export * from './smart-info.entity'; export * from './system-config.entity'; @@ -19,12 +21,13 @@ export * from './user-token.entity'; export * from './user.entity'; export const databaseEntities = [ - AssetEntity, AlbumEntity, APIKeyEntity, - UserEntity, + AssetEntity, + PartnerEntity, SharedLinkEntity, SmartInfoEntity, SystemConfigEntity, + UserEntity, UserTokenEntity, ]; diff --git a/server/libs/infra/src/entities/partner.entity.ts b/server/libs/infra/src/entities/partner.entity.ts new file mode 100644 index 0000000000000..f22eebf298032 --- /dev/null +++ b/server/libs/infra/src/entities/partner.entity.ts @@ -0,0 +1,26 @@ +import { CreateDateColumn, Entity, ManyToOne, PrimaryColumn, JoinColumn, UpdateDateColumn } from 'typeorm'; + +import { UserEntity } from './user.entity'; + +@Entity('partners') +export class PartnerEntity { + @PrimaryColumn('uuid') + sharedById!: string; + + @PrimaryColumn('uuid') + sharedWithId!: string; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true }) + @JoinColumn({ name: 'sharedById' }) + sharedBy!: UserEntity; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true }) + @JoinColumn({ name: 'sharedWithId' }) + sharedWith!: UserEntity; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index 747e19ec75d6f..51cc1bef146c2 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -9,6 +9,7 @@ import { IMachineLearningRepository, IMediaRepository, immichAppConfig, + IPartnerRepository, ISearchRepository, ISharedLinkRepository, ISmartInfoRepository, @@ -36,6 +37,7 @@ import { JobRepository, MachineLearningRepository, MediaRepository, + PartnerRepository, SharedLinkRepository, SmartInfoRepository, SystemConfigRepository, @@ -54,6 +56,7 @@ const providers: Provider[] = [ { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, { provide: IMediaRepository, useClass: MediaRepository }, + { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: ISearchRepository, useClass: TypesenseRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, diff --git a/server/libs/infra/src/migrations/1683808254676-AddPartnersTable.ts b/server/libs/infra/src/migrations/1683808254676-AddPartnersTable.ts new file mode 100644 index 0000000000000..64afb0b76cc22 --- /dev/null +++ b/server/libs/infra/src/migrations/1683808254676-AddPartnersTable.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddPartnersTable1683808254676 implements MigrationInterface { + name = 'AddPartnersTable1683808254676' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "partners" ("sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_f1cc8f73d16b367f426261a8736" PRIMARY KEY ("sharedById", "sharedWithId"))`); + await queryRunner.query(`ALTER TABLE "partners" ADD CONSTRAINT "FK_7e077a8b70b3530138610ff5e04" FOREIGN KEY ("sharedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "partners" ADD CONSTRAINT "FK_d7e875c6c60e661723dbf372fd3" FOREIGN KEY ("sharedWithId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "partners" DROP CONSTRAINT "FK_d7e875c6c60e661723dbf372fd3"`); + await queryRunner.query(`ALTER TABLE "partners" DROP CONSTRAINT "FK_7e077a8b70b3530138610ff5e04"`); + await queryRunner.query(`DROP TABLE "partners"`); + } + +} diff --git a/server/libs/infra/src/repositories/index.ts b/server/libs/infra/src/repositories/index.ts index c7f0e6081eb00..28b208c31efb1 100644 --- a/server/libs/infra/src/repositories/index.ts +++ b/server/libs/infra/src/repositories/index.ts @@ -8,6 +8,7 @@ export * from './geocoding.repository'; export * from './job.repository'; export * from './machine-learning.repository'; export * from './media.repository'; +export * from './partner.repository'; export * from './shared-link.repository'; export * from './smart-info.repository'; export * from './system-config.repository'; diff --git a/server/libs/infra/src/repositories/partner.repository.ts b/server/libs/infra/src/repositories/partner.repository.ts new file mode 100644 index 0000000000000..56fdbc531b2a5 --- /dev/null +++ b/server/libs/infra/src/repositories/partner.repository.ts @@ -0,0 +1,50 @@ +import { IPartnerRepository, PartnerIds } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PartnerEntity } from '../entities'; + +@Injectable() +export class PartnerRepository implements IPartnerRepository { + constructor(@InjectRepository(PartnerEntity) private readonly repository: Repository) {} + + getAll(userId: string): Promise { + return this.repository.find({ where: [{ sharedWithId: userId }, { sharedById: userId }] }); + } + + get({ sharedWithId, sharedById }: PartnerIds): Promise { + return this.repository.findOne({ where: { sharedById, sharedWithId } }); + } + + async create({ sharedById, sharedWithId }: PartnerIds): Promise { + await this.repository.save({ sharedBy: { id: sharedById }, sharedWith: { id: sharedWithId } }); + return this.repository.findOneOrFail({ where: { sharedById, sharedWithId } }); + } + + async remove(entity: PartnerEntity): Promise { + await this.repository.remove(entity); + } + + async hasAssetAccess(assetId: string, userId: string): Promise { + const count = await this.repository.count({ + where: { + sharedWith: { + id: userId, + }, + sharedBy: { + assets: { + id: assetId, + }, + }, + }, + relations: { + sharedWith: true, + sharedBy: { + assets: true, + }, + }, + }); + + return count == 1; + } +} diff --git a/server/libs/infra/src/repositories/shared-link.repository.ts b/server/libs/infra/src/repositories/shared-link.repository.ts index d999c28f797ab..4cd13e3245128 100644 --- a/server/libs/infra/src/repositories/shared-link.repository.ts +++ b/server/libs/infra/src/repositories/shared-link.repository.ts @@ -82,8 +82,8 @@ export class SharedLinkRepository implements ISharedLinkRepository { return this.repository.save(entity); } - remove(entity: SharedLinkEntity): Promise { - return this.repository.remove(entity); + async remove(entity: SharedLinkEntity): Promise { + await this.repository.remove(entity); } async save(entity: SharedLinkEntity): Promise { diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 15f8b1e2bd8ab..26499db26fb42 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -8,6 +8,7 @@ import { ConfigurationParameters, JobApi, OAuthApi, + PartnerApi, SearchApi, ServerInfoApi, ShareApi, @@ -20,34 +21,36 @@ import { DUMMY_BASE_URL, toPathString } from './open-api/common'; import type { ApiParams } from './types'; export class ImmichApi { - public userApi: UserApi; public albumApi: AlbumApi; public assetApi: AssetApi; public authenticationApi: AuthenticationApi; - public oauthApi: OAuthApi; - public searchApi: SearchApi; - public serverInfoApi: ServerInfoApi; public jobApi: JobApi; public keyApi: APIKeyApi; - public systemConfigApi: SystemConfigApi; + public oauthApi: OAuthApi; + public partnerApi: PartnerApi; + public searchApi: SearchApi; + public serverInfoApi: ServerInfoApi; public shareApi: ShareApi; + public systemConfigApi: SystemConfigApi; + public userApi: UserApi; private config: Configuration; constructor(params: ConfigurationParameters) { this.config = new Configuration(params); - this.userApi = new UserApi(this.config); this.albumApi = new AlbumApi(this.config); this.assetApi = new AssetApi(this.config); this.authenticationApi = new AuthenticationApi(this.config); - this.oauthApi = new OAuthApi(this.config); - this.serverInfoApi = new ServerInfoApi(this.config); this.jobApi = new JobApi(this.config); this.keyApi = new APIKeyApi(this.config); + this.oauthApi = new OAuthApi(this.config); + this.partnerApi = new PartnerApi(this.config); this.searchApi = new SearchApi(this.config); - this.systemConfigApi = new SystemConfigApi(this.config); + this.serverInfoApi = new ServerInfoApi(this.config); this.shareApi = new ShareApi(this.config); + this.systemConfigApi = new SystemConfigApi(this.config); + this.userApi = new UserApi(this.config); } private createUrl(path: string, params?: Record) { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index f6ca2bfe3df4c..602bdac3891ed 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1210,6 +1210,12 @@ export interface GetAssetByTimeBucketDto { * @memberof GetAssetByTimeBucketDto */ 'timeBucket': Array; + /** + * + * @type {string} + * @memberof GetAssetByTimeBucketDto + */ + 'userId'?: string; } /** * @@ -1223,6 +1229,12 @@ export interface GetAssetCountByTimeBucketDto { * @memberof GetAssetCountByTimeBucketDto */ 'timeGroup': TimeGroupEnum; + /** + * + * @type {string} + * @memberof GetAssetCountByTimeBucketDto + */ + 'userId'?: string; } @@ -7191,6 +7203,263 @@ export class OAuthApi extends BaseAPI { } +/** + * PartnerApi - axios parameter creator + * @export + */ +export const PartnerApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPartner: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('createPartner', 'id', id) + const localVarPath = `/partner/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {'shared-by' | 'shared-with'} direction + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPartners: async (direction: 'shared-by' | 'shared-with', options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'direction' is not null or undefined + assertParamExists('getPartners', 'direction', direction) + const localVarPath = `/partner`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (direction !== undefined) { + localVarQueryParameter['direction'] = direction; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removePartner: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('removePartner', 'id', id) + const localVarPath = `/partner/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * PartnerApi - functional programming interface + * @export + */ +export const PartnerApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = PartnerApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createPartner(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createPartner(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {'shared-by' | 'shared-with'} direction + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getPartners(direction: 'shared-by' | 'shared-with', options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getPartners(direction, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async removePartner(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removePartner(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * PartnerApi - factory interface + * @export + */ +export const PartnerApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = PartnerApiFp(configuration) + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPartner(id: string, options?: any): AxiosPromise { + return localVarFp.createPartner(id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {'shared-by' | 'shared-with'} direction + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPartners(direction: 'shared-by' | 'shared-with', options?: any): AxiosPromise> { + return localVarFp.getPartners(direction, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removePartner(id: string, options?: any): AxiosPromise { + return localVarFp.removePartner(id, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * PartnerApi - object-oriented interface + * @export + * @class PartnerApi + * @extends {BaseAPI} + */ +export class PartnerApi extends BaseAPI { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PartnerApi + */ + public createPartner(id: string, options?: AxiosRequestConfig) { + return PartnerApiFp(this.configuration).createPartner(id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {'shared-by' | 'shared-with'} direction + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PartnerApi + */ + public getPartners(direction: 'shared-by' | 'shared-with', options?: AxiosRequestConfig) { + return PartnerApiFp(this.configuration).getPartners(direction, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PartnerApi + */ + public removePartner(id: string, options?: AxiosRequestConfig) { + return PartnerApiFp(this.configuration).removePartner(id, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * SearchApi - axios parameter creator * @export diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index aa5fe2c1bcda0..a98119fac9d9c 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -8,6 +8,7 @@ import { goto } from '$app/navigation'; import ImmichLogo from '../shared-components/immich-logo.svelte'; import Button from '../elements/buttons/button.svelte'; + import { AppRoute } from '$lib/constants'; export let album: AlbumResponseDto; export let sharedUsersInAlbum: Set; @@ -138,7 +139,7 @@ {#if sharedLinks.length} + {/each} + {:else} +

+ Looks like you shared your photos with all users or you don't have any user to share with. +

+ {/if} + + {#if selectedUsers.length > 0} +
+ +
+ {/if} + + diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte new file mode 100644 index 0000000000000..d12830928c63c --- /dev/null +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -0,0 +1,98 @@ + + +
+ {#if partners.length > 0} +
+ {#each partners as partner} +
+ + +
+

+ {partner.firstName} + {partner.lastName} +

+

+ {partner.email} +

+
+ (removePartner = partner)} + logo={Close} + size={'16'} + title="Remove partner" + /> +
+ {/each} +
+ {/if} +
+ +
+
+ +{#if createPartner} + (createPartner = false)} + on:add-users={(event) => handleCreatePartners(event.detail)} + /> +{/if} + +{#if removePartner} + (removePartner = null)} + on:confirm={() => handleRemovePartner()} + /> +{/if} diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 628998149032c..da93fb8d94ce6 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -7,6 +7,7 @@ import OAuthSettings from './oauth-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; import DeviceList from './device-list.svelte'; + import PartnerSettings from './partner-settings.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; export let user: UserResponseDto; @@ -51,3 +52,7 @@ + + + + diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 7d18f47d6827f..41563b72ada7c 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -13,6 +13,7 @@ export enum AppRoute { PHOTOS = '/photos', EXPLORE = '/explore', SHARING = '/sharing', + SHARED_LINKS = '/sharing/sharedlinks', SEARCH = '/search', MAP = '/map', diff --git a/web/src/lib/models/asset-grid-state.ts b/web/src/lib/models/asset-grid-state.ts index b6d21fb39dede..23e015edb7509 100644 --- a/web/src/lib/models/asset-grid-state.ts +++ b/web/src/lib/models/asset-grid-state.ts @@ -37,4 +37,9 @@ export class AssetGridState { * Total assets that have been loaded */ assets: AssetResponseDto[] = []; + + /** + * User that owns assets + */ + userId: string | undefined; } diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index ce8dd7fda4ea2..61023c64c2db7 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -29,7 +29,8 @@ function createAssetStore() { const setInitialState = ( viewportHeight: number, viewportWidth: number, - data: AssetCountByTimeBucketResponseDto + data: AssetCountByTimeBucketResponseDto, + userId: string | undefined ) => { assetGridState.set({ viewportHeight, @@ -41,7 +42,8 @@ function createAssetStore() { assets: [], cancelToken: new AbortController() })), - assets: [] + assets: [], + userId }); // Update timeline height based on calculated bucket height @@ -64,7 +66,8 @@ function createAssetStore() { }); const { data: assets } = await api.assetApi.getAssetByTimeBucket( { - timeBucket: [bucket] + timeBucket: [bucket], + userId: _assetGridState.userId }, { signal: currentBucketData?.cancelToken.signal } ); diff --git a/web/src/routes/(user)/partners/[userId]/+page.server.ts b/web/src/routes/(user)/partners/[userId]/+page.server.ts new file mode 100644 index 0000000000000..3d6feb830e599 --- /dev/null +++ b/web/src/routes/(user)/partners/[userId]/+page.server.ts @@ -0,0 +1,21 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { AppRoute } from '$lib/constants'; + +export const load: PageServerLoad = async ({ params, parent, locals: { api } }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, AppRoute.AUTH_LOGIN); + } + + const { data: partner } = await api.userApi.getUserById(params['userId']); + + return { + user, + partner, + meta: { + title: 'Partner' + } + }; +}; diff --git a/web/src/routes/(user)/partners/[userId]/+page.svelte b/web/src/routes/(user)/partners/[userId]/+page.svelte new file mode 100644 index 0000000000000..f6f38c6375db3 --- /dev/null +++ b/web/src/routes/(user)/partners/[userId]/+page.svelte @@ -0,0 +1,65 @@ + + +
+ {#if $isMultiSelectStoreState} + assetInteractionStore.clearMultiselect()} + tailwindClasses={'bg-white shadow-md'} + > + +

+ Selected {$selectedAssets.size.toLocaleString($locale)} +

+
+ + + + +
+ {:else} + goto(AppRoute.SHARING)} + > + +

+ {data.partner.firstName} + {data.partner.lastName}'s photos +

+
+
+ {/if} + +
diff --git a/web/src/routes/(user)/sharing/+page.server.ts b/web/src/routes/(user)/sharing/+page.server.ts index 6e322e76368b5..da5ec9c5fa616 100644 --- a/web/src/routes/(user)/sharing/+page.server.ts +++ b/web/src/routes/(user)/sharing/+page.server.ts @@ -9,15 +9,18 @@ export const load = (async ({ locals: { api, user } }) => { try { const { data: sharedAlbums } = await api.albumApi.getAllAlbums(true); + const { data: partners } = await api.partnerApi.getPartners('shared-with'); return { user, sharedAlbums, + partners, meta: { title: 'Sharing' } }; } catch (e) { + console.log(e); throw redirect(302, AppRoute.AUTH_LOGIN); } }) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index 803db5dfe6911..3f16673c4b21f 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -13,6 +13,8 @@ import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import { flip } from 'svelte/animate'; import AlbumCard from '$lib/components/album-page/album-card.svelte'; + import CircleAvatar from '$lib/components/shared-components/circle-avatar.svelte'; + import { AppRoute } from '$lib/constants'; export let data: PageData; @@ -43,7 +45,7 @@ - goto('/sharing/sharedlinks')}> + goto(AppRoute.SHARED_LINKS)}>
Shared links @@ -51,29 +53,69 @@
-
-
- {#each data.sharedAlbums as album (album.id)} - - - - {/each} -
+
+ {#if data.partners.length > 0} +
+
+

Partners

+
- - {#if data.sharedAlbums.length === 0} -
- Empty shared album -

- Create a shared album to share photos and videos with people in your network -

+
+ {#each data.partners as partner} + + {/each} +
+ +
{/if} -
+ +
+
+

Albums

+
+ +
+ +
+ {#each data.sharedAlbums as album (album.id)} + + + + {/each} +
+ + + {#if data.sharedAlbums.length === 0} +
+ Empty shared album +

+ Create a shared album to share photos and videos with people in your network +

+
+ {/if} +
+
+