diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 45537ddc..42a8c310 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 7f4e48a1..25c6c7a9 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 0ac15e76..b0f72122 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 365c9646..e770c3f9 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 00000000..937978be --- /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 270cff8e..a7d0cb82 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 00000000..cf374aaf --- /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 4b487ed8..10210c2e 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 2cd3feda..619c5fe8 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 33d0d79b..591f4614 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 83a0cc91..5fa7c11b 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 00000000..fa5a59d2 --- /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 a98349cb..b9697a1f 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 8fe18bb8..dcaae0d9 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 0f98817a..6203c3e0 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 8a861734..58104d5d 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 335c13fd..11b5f152 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 942c004d..9846b43a 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 00000000..e26d16df --- /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 aef3dad7..9590132a 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 12d5f078..20d2f686 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 7e128497..b2c866f3 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 f3dca00f..b5e846fe 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 00000000..86006f17 --- /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 00000000..7e51c6fe --- /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 00000000..9e4f78ec --- /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 00000000..f29267db --- /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 00000000..0b2c22d8 --- /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 6d33c2fd..4229999a 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 e6d7c403..1eca5240 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 d728d84f..6eebc2e4 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 19b3e07b..842d2913 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 00000000..39079027 --- /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 cb892c4a..8e4a73b1 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 00000000..f22eebf2 --- /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 747e19ec..51cc1bef 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 00000000..64afb0b7 --- /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 c7f0e608..28b208c3 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 00000000..56fdbc53 --- /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 d999c28f..4cd13e32 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 15f8b1e2..26499db2 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 f6ca2bfe..602bdac3 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 aa5fe2c1..a98119fa 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 00000000..d1283092 --- /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 62899814..da93fb8d 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 7d18f47d..41563b72 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 b6d21fb3..23e015ed 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 ce8dd7fd..61023c64 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 00000000..3d6feb83 --- /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 00000000..f6f38c63 --- /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 6e322e76..da5ec9c5 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 803db5df..3f16673c 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} +
+
+