mirror of
https://github.com/immich-app/immich.git
synced 2025-04-21 07:26:25 +02:00
feat (server, web): Share with partner (#2388)
* feat(server, web): implement share with partner * chore: regenerate api * chore: regenerate api * Pass userId to getAssetCountByTimeBucket and getAssetByTimeBucket * chore: regenerate api * Use AssetGrid to view partner's assets * Remove disableNavBarActions flag * Check access to buckets * Apply suggestions from code review Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> * Remove exception rethrowing * Simplify partner access check * Create new PartnerController * chore api:generate * Use partnerApi * Remove id from PartnerResponseDto * Refactor PartnerEntity * Rename args * Remove duplicate code in getAll * Create composite primary keys for partners table * Move asset access check into PartnerCore * Remove redundant getUserAssets call * Remove unused getUserAssets method * chore: regenerate api * Simplify getAll * Replace ?? with || * Simplify PartnerRepository.create * Introduce PartnerIds interface * Replace two database migrations with one * Simplify getAll * Change PartnerResponseDto to include UserResponseDto * Move partner sharing endpoints to PartnerController * Rename ShareController to SharedLinkController * chore: regenerate api after rebase * refactor: shared link remove return type * refactor: return user response dto * chore: regenerate open api * refactor: partner getAll * refactor: partner settings event typing * chore: remove unused code * refactor: add partners modal trigger * refactor: update url for viewing partner photos * feat: update partner sharing title * refactor: rename service method names * refactor: http exception logic to service, PartnerIds interface * chore: regenerate open api * test: coverage for domain code * fix: addPartner => createPartner * fix: missed rename * refactor: more code cleanup * chore: alphabetize settings order * feat: stop sharing confirmation modal * Enhance contrast of the email in dark mode * Replace button with CircleIconButton * Fix linter warning * Fix date types for PartnerEntity * Fix PartnerEntity creation * Reset assetStore state * Change layout of the partner's assets page * Add bulk download action for partner's assets --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
4524aa0d06
commit
7f2fa23179
55 changed files with 1669 additions and 92 deletions
mobile/openapi
server
apps/immich/src
immich-openapi-specs.jsonlibs
web/src
api
lib
routes/(user)
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
|
@ -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
|
||||
|
|
3
mobile/openapi/README.md
generated
3
mobile/openapi/README.md
generated
|
@ -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 |
|
||||
|
|
1
mobile/openapi/doc/GetAssetByTimeBucketDto.md
generated
1
mobile/openapi/doc/GetAssetByTimeBucketDto.md
generated
|
@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
|
|||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**timeBucket** | **List<String>** | | [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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
180
mobile/openapi/doc/PartnerApi.md
generated
Normal file
180
mobile/openapi/doc/PartnerApi.md
generated
Normal file
|
@ -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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = 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<UserResponseDto> getPartners(direction)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = 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>**](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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = 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)
|
||||
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
@ -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';
|
||||
|
|
158
mobile/openapi/lib/api/partner_api.dart
generated
Normal file
158
mobile/openapi/lib/api/partner_api.dart
generated
Normal file
|
@ -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<Response> createPartnerWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/partner/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<UserResponseDto?> 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<Response> getPartnersWithHttpInfo(String direction,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/partner';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
queryParams.addAll(_queryParams('', 'direction', direction));
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] direction (required):
|
||||
Future<List<UserResponseDto>?> 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<UserResponseDto>') as List)
|
||||
.cast<UserResponseDto>()
|
||||
.toList();
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'DELETE /partner/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> removePartnerWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/partner/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<void> removePartner(String id,) async {
|
||||
final response = await removePartnerWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,25 +14,41 @@ class GetAssetByTimeBucketDto {
|
|||
/// Returns a new [GetAssetByTimeBucketDto] instance.
|
||||
GetAssetByTimeBucketDto({
|
||||
this.timeBucket = const [],
|
||||
this.userId,
|
||||
});
|
||||
|
||||
List<String> 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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String>().toList(growable: false)
|
||||
: const [],
|
||||
userId: mapValueOfType<String>(json, r'userId'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String>(json, r'userId'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -21,6 +21,11 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
// String userId
|
||||
test('to test the property `userId`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
|
|
@ -21,6 +21,11 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
// String userId
|
||||
test('to test the property `userId`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
|
36
mobile/openapi/test/partner_api_test.dart
generated
Normal file
36
mobile/openapi/test/partner_api_test.dart
generated
Normal file
|
@ -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<UserResponseDto> createPartner(String id) async
|
||||
test('test createPartner', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<List<UserResponseDto>> getPartners(String direction) async
|
||||
test('test getPartners', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future removePartner(String id) async
|
||||
test('test removePartner', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
});
|
||||
}
|
|
@ -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<IAssetRepository>;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||
let partnerRepositoryMock: jest.Mocked<IPartnerRepository>;
|
||||
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
@ -178,6 +186,7 @@ describe('AssetService', () => {
|
|||
jobMock,
|
||||
cryptoMock,
|
||||
storageMock,
|
||||
partnerRepositoryMock,
|
||||
);
|
||||
|
||||
when(assetRepositoryMock.get)
|
||||
|
|
|
@ -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<AssetResponseDto[]> {
|
||||
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<AssetCountByTimeBucketResponseDto> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
|
|
|
@ -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';
|
||||
|
|
36
server/apps/immich/src/controllers/partner.controller.ts
Normal file
36
server/apps/immich/src/controllers/partner.controller.ts
Normal file
|
@ -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<UserResponseDto[]> {
|
||||
return this.service.getAll(authUser, direction);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Post(':id')
|
||||
createPartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
|
||||
return this.service.create(authUser, id);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Delete(':id')
|
||||
removePartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.remove(authUser, id);
|
||||
}
|
||||
}
|
|
@ -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()
|
|
@ -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": [
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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';
|
||||
|
|
3
server/libs/domain/src/partner/index.ts
Normal file
3
server/libs/domain/src/partner/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './partner.core';
|
||||
export * from './partner.repository';
|
||||
export * from './partner.service';
|
33
server/libs/domain/src/partner/partner.core.ts
Normal file
33
server/libs/domain/src/partner/partner.core.ts
Normal file
|
@ -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<PartnerEntity[]> {
|
||||
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<PartnerEntity | null> {
|
||||
return this.repository.get(ids);
|
||||
}
|
||||
|
||||
async create(ids: PartnerIds): Promise<PartnerEntity> {
|
||||
return this.repository.create(ids);
|
||||
}
|
||||
|
||||
async remove(ids: PartnerIds): Promise<void> {
|
||||
await this.repository.remove(ids as PartnerEntity);
|
||||
}
|
||||
|
||||
hasAssetAccess(assetId: string, userId: string): Promise<boolean> {
|
||||
return this.repository.hasAssetAccess(assetId, userId);
|
||||
}
|
||||
}
|
16
server/libs/domain/src/partner/partner.repository.ts
Normal file
16
server/libs/domain/src/partner/partner.repository.ts
Normal file
|
@ -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<PartnerEntity[]>;
|
||||
get(partner: PartnerIds): Promise<PartnerEntity | null>;
|
||||
create(partner: PartnerIds): Promise<PartnerEntity>;
|
||||
remove(entity: PartnerEntity): Promise<void>;
|
||||
hasAssetAccess(assetId: string, userId: string): Promise<boolean>;
|
||||
}
|
102
server/libs/domain/src/partner/partner.service.spec.ts
Normal file
102
server/libs/domain/src/partner/partner.service.spec.ts
Normal file
|
@ -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<IPartnerRepository>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
45
server/libs/domain/src/partner/partner.service.ts
Normal file
45
server/libs/domain/src/partner/partner.service.ts
Normal file
|
@ -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<UserResponseDto> {
|
||||
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<void> {
|
||||
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<UserResponseDto[]> {
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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<SharedLinkEntity> {
|
||||
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<SharedLinkEntity>): Promise<SharedLinkEntity> {
|
||||
|
@ -54,13 +43,13 @@ export class ShareCore {
|
|||
return this.repository.save({ ...entity, userId, id });
|
||||
}
|
||||
|
||||
async remove(userId: string, id: string): Promise<SharedLinkEntity> {
|
||||
async remove(userId: string, id: string): Promise<void> {
|
||||
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[]) {
|
||||
|
|
|
@ -7,7 +7,7 @@ export interface ISharedLinkRepository {
|
|||
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
|
||||
getByKey(key: string): Promise<SharedLinkEntity | null>;
|
||||
create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
|
||||
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
|
||||
remove(entity: SharedLinkEntity): Promise<void>;
|
||||
save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
|
||||
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
|
||||
}
|
||||
|
|
|
@ -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<PartnerEntity>({
|
||||
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<PartnerEntity>({
|
||||
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,
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
11
server/libs/domain/test/partner.repository.mock.ts
Normal file
11
server/libs/domain/test/partner.repository.mock.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { IPartnerRepository } from '../src';
|
||||
|
||||
export const newPartnerRepositoryMock = (): jest.Mocked<IPartnerRepository> => {
|
||||
return {
|
||||
create: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
get: jest.fn(),
|
||||
hasAssetAccess: jest.fn(),
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
];
|
||||
|
|
26
server/libs/infra/src/entities/partner.entity.ts
Normal file
26
server/libs/infra/src/entities/partner.entity.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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 },
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPartnersTable1683808254676 implements MigrationInterface {
|
||||
name = 'AddPartnersTable1683808254676'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -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';
|
||||
|
|
50
server/libs/infra/src/repositories/partner.repository.ts
Normal file
50
server/libs/infra/src/repositories/partner.repository.ts
Normal file
|
@ -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<PartnerEntity>) {}
|
||||
|
||||
getAll(userId: string): Promise<PartnerEntity[]> {
|
||||
return this.repository.find({ where: [{ sharedWithId: userId }, { sharedById: userId }] });
|
||||
}
|
||||
|
||||
get({ sharedWithId, sharedById }: PartnerIds): Promise<PartnerEntity | null> {
|
||||
return this.repository.findOne({ where: { sharedById, sharedWithId } });
|
||||
}
|
||||
|
||||
async create({ sharedById, sharedWithId }: PartnerIds): Promise<PartnerEntity> {
|
||||
await this.repository.save({ sharedBy: { id: sharedById }, sharedWith: { id: sharedWithId } });
|
||||
return this.repository.findOneOrFail({ where: { sharedById, sharedWithId } });
|
||||
}
|
||||
|
||||
async remove(entity: PartnerEntity): Promise<void> {
|
||||
await this.repository.remove(entity);
|
||||
}
|
||||
|
||||
async hasAssetAccess(assetId: string, userId: string): Promise<boolean> {
|
||||
const count = await this.repository.count({
|
||||
where: {
|
||||
sharedWith: {
|
||||
id: userId,
|
||||
},
|
||||
sharedBy: {
|
||||
assets: {
|
||||
id: assetId,
|
||||
},
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
sharedWith: true,
|
||||
sharedBy: {
|
||||
assets: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return count == 1;
|
||||
}
|
||||
}
|
|
@ -82,8 +82,8 @@ export class SharedLinkRepository implements ISharedLinkRepository {
|
|||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
|
||||
return this.repository.remove(entity);
|
||||
async remove(entity: SharedLinkEntity): Promise<void> {
|
||||
await this.repository.remove(entity);
|
||||
}
|
||||
|
||||
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
|
||||
|
|
|
@ -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<string, unknown>) {
|
||||
|
|
269
web/src/api/open-api/api.ts
generated
269
web/src/api/open-api/api.ts
generated
|
@ -1210,6 +1210,12 @@ export interface GetAssetByTimeBucketDto {
|
|||
* @memberof GetAssetByTimeBucketDto
|
||||
*/
|
||||
'timeBucket': Array<string>;
|
||||
/**
|
||||
*
|
||||
* @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<RequestArgs> => {
|
||||
// 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<RequestArgs> => {
|
||||
// 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<RequestArgs> => {
|
||||
// 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<UserResponseDto>> {
|
||||
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<Array<UserResponseDto>>> {
|
||||
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<void>> {
|
||||
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<UserResponseDto> {
|
||||
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<Array<UserResponseDto>> {
|
||||
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<void> {
|
||||
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
|
||||
|
|
|
@ -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<UserResponseDto>;
|
||||
|
@ -138,7 +139,7 @@
|
|||
{#if sharedLinks.length}
|
||||
<button
|
||||
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
|
||||
on:click={() => goto('/sharing/sharedlinks')}
|
||||
on:click={() => goto(AppRoute.SHARED_LINKS)}
|
||||
>
|
||||
<ShareCircle size={24} />
|
||||
<p class="text-sm">View links</p>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import { UserResponseDto } from '@api';
|
||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
|
||||
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
|
||||
|
@ -17,6 +18,7 @@
|
|||
OnScrollbarDragDetail
|
||||
} from '../shared-components/scrollbar/scrollbar.svelte';
|
||||
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
export let isAlbumSelectionMode = false;
|
||||
|
||||
let viewportHeight = 0;
|
||||
|
@ -26,11 +28,12 @@
|
|||
|
||||
onMount(async () => {
|
||||
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
|
||||
timeGroup: TimeGroupEnum.Month
|
||||
timeGroup: TimeGroupEnum.Month,
|
||||
userId: user?.id
|
||||
});
|
||||
bucketInfo = assetCountByTimebucket;
|
||||
|
||||
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
|
||||
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id);
|
||||
|
||||
// Get asset bucket if bucket height is smaller than viewport height
|
||||
let bucketsToFetchInitially: string[] = [];
|
||||
|
@ -50,6 +53,10 @@
|
|||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.setInitialState(0, 0, { totalCount: 0, buckets: [] }, undefined);
|
||||
});
|
||||
|
||||
function intersectedHandler(event: CustomEvent) {
|
||||
const el = event.detail as HTMLElement;
|
||||
const target = el.firstChild as HTMLElement;
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
id="immich-modal"
|
||||
style:z-index={zIndex}
|
||||
transition:fade={{ duration: 100, easing: quintOut }}
|
||||
class="fixed top-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
|
||||
class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
use:clickOutside
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts">
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import BaseModal from '../shared-components/base-modal.svelte';
|
||||
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
||||
import ImmichLogo from '../shared-components/immich-logo.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
|
||||
let availableUsers: UserResponseDto[] = [];
|
||||
let selectedUsers: UserResponseDto[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void; 'add-users': UserResponseDto[] }>();
|
||||
|
||||
onMount(async () => {
|
||||
// TODO: update endpoint to have a query param for deleted users
|
||||
let { data: users } = await api.userApi.getAllUsers(false);
|
||||
|
||||
// remove soft deleted users
|
||||
users = users.filter((user) => !user.deletedAt);
|
||||
|
||||
// exclude partners from the list of users available for selection
|
||||
const { data: partners } = await api.partnerApi.getPartners('shared-by');
|
||||
const partnerIds = partners.map((partner) => partner.id);
|
||||
availableUsers = users.filter((user) => !partnerIds.includes(user.id));
|
||||
});
|
||||
|
||||
const selectUser = (user: UserResponseDto) => {
|
||||
if (selectedUsers.includes(user)) {
|
||||
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
|
||||
} else {
|
||||
selectedUsers = [...selectedUsers, user];
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<BaseModal on:close={() => dispatch('close')}>
|
||||
<svelte:fragment slot="title">
|
||||
<span class="flex gap-2 place-items-center">
|
||||
<ImmichLogo width={24} />
|
||||
<p class="font-medium">Add partner</p>
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="max-h-[300px] overflow-y-auto immich-scrollbar">
|
||||
{#if availableUsers.length > 0}
|
||||
{#each availableUsers as user}
|
||||
<button
|
||||
on:click={() => selectUser(user)}
|
||||
class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
|
||||
>
|
||||
{#if selectedUsers.includes(user)}
|
||||
<span
|
||||
class="bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-bg rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl dark:border-immich-dark-gray"
|
||||
>✓</span
|
||||
>
|
||||
{:else}
|
||||
<CircleAvatar {user} />
|
||||
{/if}
|
||||
|
||||
<div class="text-left">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.firstName}
|
||||
{user.lastName}
|
||||
</p>
|
||||
<p class="text-xs ">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-sm p-5">
|
||||
Looks like you shared your photos with all users or you don't have any user to share with.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if selectedUsers.length > 0}
|
||||
<div class="flex place-content-end p-5 ">
|
||||
<Button size="sm" rounded="lg" on:click={() => dispatch('add-users', selectedUsers)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</BaseModal>
|
|
@ -0,0 +1,98 @@
|
|||
<script lang="ts">
|
||||
import { UserResponseDto, api } from '@api';
|
||||
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import PartnerSelectionModal from './partner-selection-modal.svelte';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import { onMount } from 'svelte';
|
||||
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
|
||||
let partners: UserResponseDto[] = [];
|
||||
let createPartner = false;
|
||||
let removePartner: UserResponseDto | null = null;
|
||||
|
||||
const refreshPartners = async () => {
|
||||
const { data } = await api.partnerApi.getPartners('shared-by');
|
||||
partners = data;
|
||||
};
|
||||
|
||||
const handleRemovePartner = async () => {
|
||||
if (!removePartner) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.partnerApi.removePartner(removePartner.id);
|
||||
removePartner = null;
|
||||
await refreshPartners();
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to remove partner');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePartners = async (users: UserResponseDto[]) => {
|
||||
try {
|
||||
for (const user of users) {
|
||||
await api.partnerApi.createPartner(user.id);
|
||||
}
|
||||
|
||||
await refreshPartners();
|
||||
createPartner = false;
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to add partners');
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await refreshPartners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
{#if partners.length > 0}
|
||||
<div class="flex flex-row gap-4">
|
||||
{#each partners as partner}
|
||||
<div class="flex rounded-lg gap-4 py-4 px-5 transition-all">
|
||||
<CircleAvatar user={partner} />
|
||||
|
||||
<div class="text-left">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{partner.firstName}
|
||||
{partner.lastName}
|
||||
</p>
|
||||
<p class="text-xs text-immich-fg/75 dark:text-immich-dark-fg/75">
|
||||
{partner.email}
|
||||
</p>
|
||||
</div>
|
||||
<CircleIconButton
|
||||
on:click={() => (removePartner = partner)}
|
||||
logo={Close}
|
||||
size={'16'}
|
||||
title="Remove partner"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<Button size="sm" on:click={() => (createPartner = true)}>Add partner</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if createPartner}
|
||||
<PartnerSelectionModal
|
||||
on:close={() => (createPartner = false)}
|
||||
on:add-users={(event) => handleCreatePartners(event.detail)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if removePartner}
|
||||
<ConfirmDialogue
|
||||
title="Stop sharing your photos?"
|
||||
prompt="{removePartner.firstName} will no longer be able to access your photos."
|
||||
on:cancel={() => (removePartner = null)}
|
||||
on:confirm={() => handleRemovePartner()}
|
||||
/>
|
||||
{/if}
|
|
@ -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 @@
|
|||
<SettingAccordion title="Password" subtitle="Change your password">
|
||||
<ChangePasswordSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion title="Sharing" subtitle="Manage sharing with partners">
|
||||
<PartnerSettings />
|
||||
</SettingAccordion>
|
||||
|
|
|
@ -13,6 +13,7 @@ export enum AppRoute {
|
|||
PHOTOS = '/photos',
|
||||
EXPLORE = '/explore',
|
||||
SHARING = '/sharing',
|
||||
SHARED_LINKS = '/sharing/sharedlinks',
|
||||
SEARCH = '/search',
|
||||
MAP = '/map',
|
||||
|
||||
|
|
|
@ -37,4 +37,9 @@ export class AssetGridState {
|
|||
* Total assets that have been loaded
|
||||
*/
|
||||
assets: AssetResponseDto[] = [];
|
||||
|
||||
/**
|
||||
* User that owns assets
|
||||
*/
|
||||
userId: string | undefined;
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
|
|
21
web/src/routes/(user)/partners/[userId]/+page.server.ts
Normal file
21
web/src/routes/(user)/partners/[userId]/+page.server.ts
Normal file
|
@ -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'
|
||||
}
|
||||
};
|
||||
};
|
65
web/src/routes/(user)/partners/[userId]/+page.svelte
Normal file
65
web/src/routes/(user)/partners/[userId]/+page.svelte
Normal file
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { bulkDownload } from '$lib/utils/asset-utils';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
||||
import {
|
||||
assetInteractionStore,
|
||||
isMultiSelectStoreState,
|
||||
selectedAssets
|
||||
} from '$lib/stores/asset-interaction.store';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const handleDownloadFiles = async () => {
|
||||
await bulkDownload('immich', Array.from($selectedAssets), () => {
|
||||
assetInteractionStore.clearMultiselect();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<main class="grid h-screen pt-[4.25rem] bg-immich-bg dark:bg-immich-dark-bg">
|
||||
{#if $isMultiSelectStoreState}
|
||||
<ControlAppBar
|
||||
showBackButton
|
||||
backIcon={Close}
|
||||
on:close-button-click={() => assetInteractionStore.clearMultiselect()}
|
||||
tailwindClasses={'bg-white shadow-md'}
|
||||
>
|
||||
<svelte:fragment slot="leading">
|
||||
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
Selected {$selectedAssets.size.toLocaleString($locale)}
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="trailing">
|
||||
<CircleIconButton
|
||||
title="Download"
|
||||
logo={CloudDownloadOutline}
|
||||
on:click={handleDownloadFiles}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{:else}
|
||||
<ControlAppBar
|
||||
showBackButton
|
||||
backIcon={ArrowLeft}
|
||||
on:close-button-click={() => goto(AppRoute.SHARING)}
|
||||
>
|
||||
<svelte:fragment slot="leading">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{data.partner.firstName}
|
||||
{data.partner.lastName}'s photos
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
<AssetGrid user={data.partner} />
|
||||
</main>
|
|
@ -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;
|
||||
|
|
|
@ -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 @@
|
|||
</div>
|
||||
</LinkButton>
|
||||
|
||||
<LinkButton on:click={() => goto('/sharing/sharedlinks')}>
|
||||
<LinkButton on:click={() => goto(AppRoute.SHARED_LINKS)}>
|
||||
<div class="flex place-items-center gap-x-1 text-sm flex-wrap justify-center">
|
||||
<Link size="18" class="shrink-0" />
|
||||
<span class="max-sm:text-xs leading-none">Shared links</span>
|
||||
|
@ -51,29 +53,69 @@
|
|||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]">
|
||||
{#each data.sharedAlbums as album (album.id)}
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
href={`albums/${album.id}`}
|
||||
animate:flip={{ duration: 200 }}
|
||||
>
|
||||
<AlbumCard {album} user={data.user} isSharingView />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mx-4 flex flex-col">
|
||||
{#if data.partners.length > 0}
|
||||
<div class="mb-6 mt-2">
|
||||
<div>
|
||||
<p class="mb-4 dark:text-immich-dark-fg font-medium">Partners</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty List -->
|
||||
{#if data.sharedAlbums.length === 0}
|
||||
<div
|
||||
class="border dark:border-immich-dark-gray p-5 md:w-[500px] w-2/3 m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center dark:text-immich-dark-fg"
|
||||
>
|
||||
<img src={empty2Url} alt="Empty shared album" width="500" draggable="false" />
|
||||
<p class="text-center text-immich-text-gray-500">
|
||||
Create a shared album to share photos and videos with people in your network
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap gap-4">
|
||||
{#each data.partners as partner}
|
||||
<button
|
||||
on:click={() => goto(`/partners/${partner.id}`)}
|
||||
class="flex rounded-lg gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
|
||||
>
|
||||
<CircleAvatar user={partner} />
|
||||
|
||||
<div class="text-left">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{partner.firstName}
|
||||
{partner.lastName}
|
||||
</p>
|
||||
<p class="text-xs text-immich-fg/75 dark:text-immich-dark-fg/75">
|
||||
{partner.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="dark:border-immich-dark-gray mb-4" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="mb-6 mt-2">
|
||||
<div>
|
||||
<p class="mb-4 dark:text-immich-dark-fg font-medium">Albums</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Share Album List -->
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]">
|
||||
{#each data.sharedAlbums as album (album.id)}
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
href={`albums/${album.id}`}
|
||||
animate:flip={{ duration: 200 }}
|
||||
>
|
||||
<AlbumCard {album} user={data.user} isSharingView />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Empty List -->
|
||||
{#if data.sharedAlbums.length === 0}
|
||||
<div
|
||||
class="border dark:border-immich-dark-gray p-5 md:w-[500px] w-2/3 m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center dark:text-immich-dark-fg"
|
||||
>
|
||||
<img src={empty2Url} alt="Empty shared album" width="500" draggable="false" />
|
||||
<p class="text-center text-immich-text-gray-500">
|
||||
Create a shared album to share photos and videos with people in your network
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
|
|
Loading…
Add table
Reference in a new issue