diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 45537ddc34..42a8c3106d 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 7f4e48a167..25c6c7a97a 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 0ac15e768a..b0f7212293 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 365c9646f6..e770c3f9bb 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 0000000000..937978befb --- /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 270cff8eb0..a7d0cb827c 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 0000000000..cf374aafe5 --- /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 4b487ed870..10210c2e6b 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 2cd3feda83..619c5fe868 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 33d0d79b90..591f461497 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 83a0cc9199..5fa7c11bec 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 0000000000..fa5a59d2ad --- /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 a98349cb3a..b9697a1faf 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 8fe18bb836..dcaae0d956 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 0f98817a40..6203c3e04e 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 8a861734d9..58104d5d68 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 335c13fd83..11b5f15257 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 942c004d92..9846b43a33 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 0000000000..e26d16dfec --- /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 aef3dad793..9590132a66 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 12d5f07812..20d2f686a4 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 7e12849728..b2c866f3ac 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 f3dca00f95..b5e846fe97 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 0000000000..86006f17fe --- /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 0000000000..7e51c6fede --- /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 0000000000..9e4f78ec12 --- /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 0000000000..f29267dbd0 --- /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 0000000000..0b2c22d84a --- /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 6d33c2fd5a..4229999a4e 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 e6d7c40356..1eca524032 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 d728d84f8f..6eebc2e474 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 19b3e07beb..842d291319 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 0000000000..390790275a --- /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 cb892c4a58..8e4a73b147 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 0000000000..f22eebf298 --- /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 747e19ec75..51cc1bef14 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 0000000000..64afb0b76c --- /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 c7f0e6081e..28b208c31e 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 0000000000..56fdbc531b --- /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 d999c28f79..4cd13e3245 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 15f8b1e2bd..26499db26f 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 f6ca2bfe3d..602bdac389 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 aa5fe2c1bc..a98119fac9 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 0000000000..d12830928c --- /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 6289981490..da93fb8d94 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 7d18f47d68..41563b72ad 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 b6d21fb39d..23e015edb7 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 ce8dd7fda4..61023c64c2 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 0000000000..3d6feb830e --- /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 0000000000..f6f38c6375 --- /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 6e322e7636..da5ec9c5fa 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 803db5dfe6..3f16673c4b 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} +
+
+