feat(server, web): quotas (#4471)

* feat: quotas

* chore: open api

* chore: update status box and upload error message

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
cfitzw 2024-01-12 18:43:36 -06:00 committed by GitHub
parent f4edb6c4bd
commit deb1f970a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 646 additions and 118 deletions

View File

@ -13,6 +13,7 @@ Name | Type | Description | Notes
**memoriesEnabled** | **bool** | | [optional] **memoriesEnabled** | **bool** | | [optional]
**name** | **String** | | **name** | **String** | |
**password** | **String** | | **password** | **String** | |
**quotaSizeInBytes** | **int** | | [optional]
**storageLabel** | **String** | | [optional] **storageLabel** | **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) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -20,6 +20,8 @@ Name | Type | Description | Notes
**name** | **String** | | **name** | **String** | |
**oauthId** | **String** | | **oauthId** | **String** | |
**profileImagePath** | **String** | | **profileImagePath** | **String** | |
**quotaSizeInBytes** | **int** | |
**quotaUsageInBytes** | **int** | |
**shouldChangePassword** | **bool** | | **shouldChangePassword** | **bool** | |
**storageLabel** | **String** | | **storageLabel** | **String** | |
**updatedAt** | [**DateTime**](DateTime.md) | | **updatedAt** | [**DateTime**](DateTime.md) | |

View File

@ -16,6 +16,7 @@ Name | Type | Description | Notes
**memoriesEnabled** | **bool** | | [optional] **memoriesEnabled** | **bool** | | [optional]
**name** | **String** | | [optional] **name** | **String** | | [optional]
**password** | **String** | | [optional] **password** | **String** | | [optional]
**quotaSizeInBytes** | **int** | | [optional]
**shouldChangePassword** | **bool** | | [optional] **shouldChangePassword** | **bool** | | [optional]
**storageLabel** | **String** | | [optional] **storageLabel** | **String** | | [optional]

View File

@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**photos** | **int** | | **photos** | **int** | |
**quotaSizeInBytes** | **int** | |
**usage** | **int** | | **usage** | **int** | |
**userId** | **String** | | **userId** | **String** | |
**userName** | **String** | | **userName** | **String** | |

View File

@ -19,6 +19,8 @@ Name | Type | Description | Notes
**name** | **String** | | **name** | **String** | |
**oauthId** | **String** | | **oauthId** | **String** | |
**profileImagePath** | **String** | | **profileImagePath** | **String** | |
**quotaSizeInBytes** | **int** | |
**quotaUsageInBytes** | **int** | |
**shouldChangePassword** | **bool** | | **shouldChangePassword** | **bool** | |
**storageLabel** | **String** | | **storageLabel** | **String** | |
**updatedAt** | [**DateTime**](DateTime.md) | | **updatedAt** | [**DateTime**](DateTime.md) | |

View File

@ -18,6 +18,7 @@ class CreateUserDto {
this.memoriesEnabled, this.memoriesEnabled,
required this.name, required this.name,
required this.password, required this.password,
this.quotaSizeInBytes,
this.storageLabel, this.storageLabel,
}); });
@ -37,6 +38,8 @@ class CreateUserDto {
String password; String password;
int? quotaSizeInBytes;
String? storageLabel; String? storageLabel;
@override @override
@ -46,6 +49,7 @@ class CreateUserDto {
other.memoriesEnabled == memoriesEnabled && other.memoriesEnabled == memoriesEnabled &&
other.name == name && other.name == name &&
other.password == password && other.password == password &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.storageLabel == storageLabel; other.storageLabel == storageLabel;
@override @override
@ -56,10 +60,11 @@ class CreateUserDto {
(memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) + (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) +
(name.hashCode) + (name.hashCode) +
(password.hashCode) + (password.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode); (storageLabel == null ? 0 : storageLabel!.hashCode);
@override @override
String toString() => 'CreateUserDto[email=$email, externalPath=$externalPath, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, storageLabel=$storageLabel]'; String toString() => 'CreateUserDto[email=$email, externalPath=$externalPath, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -76,6 +81,11 @@ class CreateUserDto {
} }
json[r'name'] = this.name; json[r'name'] = this.name;
json[r'password'] = this.password; json[r'password'] = this.password;
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else {
// json[r'quotaSizeInBytes'] = null;
}
if (this.storageLabel != null) { if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel; json[r'storageLabel'] = this.storageLabel;
} else { } else {
@ -97,6 +107,7 @@ class CreateUserDto {
memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'), memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'),
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
password: mapValueOfType<String>(json, r'password')!, password: mapValueOfType<String>(json, r'password')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
storageLabel: mapValueOfType<String>(json, r'storageLabel'), storageLabel: mapValueOfType<String>(json, r'storageLabel'),
); );
} }

View File

@ -25,6 +25,8 @@ class PartnerResponseDto {
required this.name, required this.name,
required this.oauthId, required this.oauthId,
required this.profileImagePath, required this.profileImagePath,
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
required this.shouldChangePassword, required this.shouldChangePassword,
required this.storageLabel, required this.storageLabel,
required this.updatedAt, required this.updatedAt,
@ -66,6 +68,10 @@ class PartnerResponseDto {
String profileImagePath; String profileImagePath;
int? quotaSizeInBytes;
int quotaUsageInBytes;
bool shouldChangePassword; bool shouldChangePassword;
String? storageLabel; String? storageLabel;
@ -86,6 +92,8 @@ class PartnerResponseDto {
other.name == name && other.name == name &&
other.oauthId == oauthId && other.oauthId == oauthId &&
other.profileImagePath == profileImagePath && other.profileImagePath == profileImagePath &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
other.shouldChangePassword == shouldChangePassword && other.shouldChangePassword == shouldChangePassword &&
other.storageLabel == storageLabel && other.storageLabel == storageLabel &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt;
@ -105,12 +113,14 @@ class PartnerResponseDto {
(name.hashCode) + (name.hashCode) +
(oauthId.hashCode) + (oauthId.hashCode) +
(profileImagePath.hashCode) + (profileImagePath.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes.hashCode) +
(shouldChangePassword.hashCode) + (shouldChangePassword.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) + (storageLabel == null ? 0 : storageLabel!.hashCode) +
(updatedAt.hashCode); (updatedAt.hashCode);
@override @override
String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]'; String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -142,6 +152,12 @@ class PartnerResponseDto {
json[r'name'] = this.name; json[r'name'] = this.name;
json[r'oauthId'] = this.oauthId; json[r'oauthId'] = this.oauthId;
json[r'profileImagePath'] = this.profileImagePath; json[r'profileImagePath'] = this.profileImagePath;
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else {
// json[r'quotaSizeInBytes'] = null;
}
json[r'quotaUsageInBytes'] = this.quotaUsageInBytes;
json[r'shouldChangePassword'] = this.shouldChangePassword; json[r'shouldChangePassword'] = this.shouldChangePassword;
if (this.storageLabel != null) { if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel; json[r'storageLabel'] = this.storageLabel;
@ -172,6 +188,8 @@ class PartnerResponseDto {
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
oauthId: mapValueOfType<String>(json, r'oauthId')!, oauthId: mapValueOfType<String>(json, r'oauthId')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!, profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'), storageLabel: mapValueOfType<String>(json, r'storageLabel'),
updatedAt: mapDateTime(json, r'updatedAt', '')!, updatedAt: mapDateTime(json, r'updatedAt', '')!,
@ -232,6 +250,8 @@ class PartnerResponseDto {
'name', 'name',
'oauthId', 'oauthId',
'profileImagePath', 'profileImagePath',
'quotaSizeInBytes',
'quotaUsageInBytes',
'shouldChangePassword', 'shouldChangePassword',
'storageLabel', 'storageLabel',
'updatedAt', 'updatedAt',

View File

@ -21,6 +21,7 @@ class UpdateUserDto {
this.memoriesEnabled, this.memoriesEnabled,
this.name, this.name,
this.password, this.password,
this.quotaSizeInBytes,
this.shouldChangePassword, this.shouldChangePassword,
this.storageLabel, this.storageLabel,
}); });
@ -83,6 +84,8 @@ class UpdateUserDto {
/// ///
String? password; String? password;
int? quotaSizeInBytes;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
@ -109,6 +112,7 @@ class UpdateUserDto {
other.memoriesEnabled == memoriesEnabled && other.memoriesEnabled == memoriesEnabled &&
other.name == name && other.name == name &&
other.password == password && other.password == password &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.shouldChangePassword == shouldChangePassword && other.shouldChangePassword == shouldChangePassword &&
other.storageLabel == storageLabel; other.storageLabel == storageLabel;
@ -123,11 +127,12 @@ class UpdateUserDto {
(memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) + (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) +
(name == null ? 0 : name!.hashCode) + (name == null ? 0 : name!.hashCode) +
(password == null ? 0 : password!.hashCode) + (password == null ? 0 : password!.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) + (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode); (storageLabel == null ? 0 : storageLabel!.hashCode);
@override @override
String toString() => 'UpdateUserDto[avatarColor=$avatarColor, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; String toString() => 'UpdateUserDto[avatarColor=$avatarColor, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -167,6 +172,11 @@ class UpdateUserDto {
} else { } else {
// json[r'password'] = null; // json[r'password'] = null;
} }
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else {
// json[r'quotaSizeInBytes'] = null;
}
if (this.shouldChangePassword != null) { if (this.shouldChangePassword != null) {
json[r'shouldChangePassword'] = this.shouldChangePassword; json[r'shouldChangePassword'] = this.shouldChangePassword;
} else { } else {
@ -196,6 +206,7 @@ class UpdateUserDto {
memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'), memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
password: mapValueOfType<String>(json, r'password'), password: mapValueOfType<String>(json, r'password'),
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'), shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
storageLabel: mapValueOfType<String>(json, r'storageLabel'), storageLabel: mapValueOfType<String>(json, r'storageLabel'),
); );

View File

@ -14,6 +14,7 @@ class UsageByUserDto {
/// Returns a new [UsageByUserDto] instance. /// Returns a new [UsageByUserDto] instance.
UsageByUserDto({ UsageByUserDto({
required this.photos, required this.photos,
required this.quotaSizeInBytes,
required this.usage, required this.usage,
required this.userId, required this.userId,
required this.userName, required this.userName,
@ -22,6 +23,8 @@ class UsageByUserDto {
int photos; int photos;
int? quotaSizeInBytes;
int usage; int usage;
String userId; String userId;
@ -33,6 +36,7 @@ class UsageByUserDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto && bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto &&
other.photos == photos && other.photos == photos &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.usage == usage && other.usage == usage &&
other.userId == userId && other.userId == userId &&
other.userName == userName && other.userName == userName &&
@ -42,17 +46,23 @@ class UsageByUserDto {
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(photos.hashCode) + (photos.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(usage.hashCode) + (usage.hashCode) +
(userId.hashCode) + (userId.hashCode) +
(userName.hashCode) + (userName.hashCode) +
(videos.hashCode); (videos.hashCode);
@override @override
String toString() => 'UsageByUserDto[photos=$photos, usage=$usage, userId=$userId, userName=$userName, videos=$videos]'; String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, userId=$userId, userName=$userName, videos=$videos]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'photos'] = this.photos; json[r'photos'] = this.photos;
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else {
// json[r'quotaSizeInBytes'] = null;
}
json[r'usage'] = this.usage; json[r'usage'] = this.usage;
json[r'userId'] = this.userId; json[r'userId'] = this.userId;
json[r'userName'] = this.userName; json[r'userName'] = this.userName;
@ -69,6 +79,7 @@ class UsageByUserDto {
return UsageByUserDto( return UsageByUserDto(
photos: mapValueOfType<int>(json, r'photos')!, photos: mapValueOfType<int>(json, r'photos')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
usage: mapValueOfType<int>(json, r'usage')!, usage: mapValueOfType<int>(json, r'usage')!,
userId: mapValueOfType<String>(json, r'userId')!, userId: mapValueOfType<String>(json, r'userId')!,
userName: mapValueOfType<String>(json, r'userName')!, userName: mapValueOfType<String>(json, r'userName')!,
@ -121,6 +132,7 @@ class UsageByUserDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'photos', 'photos',
'quotaSizeInBytes',
'usage', 'usage',
'userId', 'userId',
'userName', 'userName',

View File

@ -24,6 +24,8 @@ class UserResponseDto {
required this.name, required this.name,
required this.oauthId, required this.oauthId,
required this.profileImagePath, required this.profileImagePath,
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
required this.shouldChangePassword, required this.shouldChangePassword,
required this.storageLabel, required this.storageLabel,
required this.updatedAt, required this.updatedAt,
@ -57,6 +59,10 @@ class UserResponseDto {
String profileImagePath; String profileImagePath;
int? quotaSizeInBytes;
int quotaUsageInBytes;
bool shouldChangePassword; bool shouldChangePassword;
String? storageLabel; String? storageLabel;
@ -76,6 +82,8 @@ class UserResponseDto {
other.name == name && other.name == name &&
other.oauthId == oauthId && other.oauthId == oauthId &&
other.profileImagePath == profileImagePath && other.profileImagePath == profileImagePath &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
other.shouldChangePassword == shouldChangePassword && other.shouldChangePassword == shouldChangePassword &&
other.storageLabel == storageLabel && other.storageLabel == storageLabel &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt;
@ -94,12 +102,14 @@ class UserResponseDto {
(name.hashCode) + (name.hashCode) +
(oauthId.hashCode) + (oauthId.hashCode) +
(profileImagePath.hashCode) + (profileImagePath.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes.hashCode) +
(shouldChangePassword.hashCode) + (shouldChangePassword.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) + (storageLabel == null ? 0 : storageLabel!.hashCode) +
(updatedAt.hashCode); (updatedAt.hashCode);
@override @override
String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]'; String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -126,6 +136,12 @@ class UserResponseDto {
json[r'name'] = this.name; json[r'name'] = this.name;
json[r'oauthId'] = this.oauthId; json[r'oauthId'] = this.oauthId;
json[r'profileImagePath'] = this.profileImagePath; json[r'profileImagePath'] = this.profileImagePath;
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else {
// json[r'quotaSizeInBytes'] = null;
}
json[r'quotaUsageInBytes'] = this.quotaUsageInBytes;
json[r'shouldChangePassword'] = this.shouldChangePassword; json[r'shouldChangePassword'] = this.shouldChangePassword;
if (this.storageLabel != null) { if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel; json[r'storageLabel'] = this.storageLabel;
@ -155,6 +171,8 @@ class UserResponseDto {
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
oauthId: mapValueOfType<String>(json, r'oauthId')!, oauthId: mapValueOfType<String>(json, r'oauthId')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!, profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'), storageLabel: mapValueOfType<String>(json, r'storageLabel'),
updatedAt: mapDateTime(json, r'updatedAt', '')!, updatedAt: mapDateTime(json, r'updatedAt', '')!,
@ -215,6 +233,8 @@ class UserResponseDto {
'name', 'name',
'oauthId', 'oauthId',
'profileImagePath', 'profileImagePath',
'quotaSizeInBytes',
'quotaUsageInBytes',
'shouldChangePassword', 'shouldChangePassword',
'storageLabel', 'storageLabel',
'updatedAt', 'updatedAt',

View File

@ -41,6 +41,11 @@ void main() {
// TODO // TODO
}); });
// int quotaSizeInBytes
test('to test the property `quotaSizeInBytes`', () async {
// TODO
});
// String storageLabel // String storageLabel
test('to test the property `storageLabel`', () async { test('to test the property `storageLabel`', () async {
// TODO // TODO

View File

@ -76,6 +76,16 @@ void main() {
// TODO // TODO
}); });
// int quotaSizeInBytes
test('to test the property `quotaSizeInBytes`', () async {
// TODO
});
// int quotaUsageInBytes
test('to test the property `quotaUsageInBytes`', () async {
// TODO
});
// bool shouldChangePassword // bool shouldChangePassword
test('to test the property `shouldChangePassword`', () async { test('to test the property `shouldChangePassword`', () async {
// TODO // TODO

View File

@ -56,6 +56,11 @@ void main() {
// TODO // TODO
}); });
// int quotaSizeInBytes
test('to test the property `quotaSizeInBytes`', () async {
// TODO
});
// bool shouldChangePassword // bool shouldChangePassword
test('to test the property `shouldChangePassword`', () async { test('to test the property `shouldChangePassword`', () async {
// TODO // TODO

View File

@ -21,6 +21,11 @@ void main() {
// TODO // TODO
}); });
// int quotaSizeInBytes
test('to test the property `quotaSizeInBytes`', () async {
// TODO
});
// int usage // int usage
test('to test the property `usage`', () async { test('to test the property `usage`', () async {
// TODO // TODO

View File

@ -71,6 +71,16 @@ void main() {
// TODO // TODO
}); });
// int quotaSizeInBytes
test('to test the property `quotaSizeInBytes`', () async {
// TODO
});
// int quotaUsageInBytes
test('to test the property `quotaUsageInBytes`', () async {
// TODO
});
// bool shouldChangePassword // bool shouldChangePassword
test('to test the property `shouldChangePassword`', () async { test('to test the property `shouldChangePassword`', () async {
// TODO // TODO

View File

@ -7409,6 +7409,11 @@
"password": { "password": {
"type": "string" "type": "string"
}, },
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"storageLabel": { "storageLabel": {
"nullable": true, "nullable": true,
"type": "string" "type": "string"
@ -8192,6 +8197,15 @@
"profileImagePath": { "profileImagePath": {
"type": "string" "type": "string"
}, },
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"quotaUsageInBytes": {
"format": "int64",
"type": "integer"
},
"shouldChangePassword": { "shouldChangePassword": {
"type": "boolean" "type": "boolean"
}, },
@ -8206,10 +8220,8 @@
}, },
"required": [ "required": [
"avatarColor", "avatarColor",
"id", "quotaSizeInBytes",
"name", "quotaUsageInBytes",
"email",
"profileImagePath",
"storageLabel", "storageLabel",
"externalPath", "externalPath",
"shouldChangePassword", "shouldChangePassword",
@ -8217,7 +8229,11 @@
"createdAt", "createdAt",
"deletedAt", "deletedAt",
"updatedAt", "updatedAt",
"oauthId" "oauthId",
"id",
"name",
"email",
"profileImagePath"
], ],
"type": "object" "type": "object"
}, },
@ -9757,6 +9773,11 @@
"password": { "password": {
"type": "string" "type": "string"
}, },
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"shouldChangePassword": { "shouldChangePassword": {
"type": "boolean" "type": "boolean"
}, },
@ -9774,6 +9795,11 @@
"photos": { "photos": {
"type": "integer" "type": "integer"
}, },
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"usage": { "usage": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@ -9793,7 +9819,8 @@
"userName", "userName",
"photos", "photos",
"videos", "videos",
"usage" "usage",
"quotaSizeInBytes"
], ],
"type": "object" "type": "object"
}, },
@ -9878,6 +9905,15 @@
"profileImagePath": { "profileImagePath": {
"type": "string" "type": "string"
}, },
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"quotaUsageInBytes": {
"format": "int64",
"type": "integer"
},
"shouldChangePassword": { "shouldChangePassword": {
"type": "boolean" "type": "boolean"
}, },
@ -9892,10 +9928,8 @@
}, },
"required": [ "required": [
"avatarColor", "avatarColor",
"id", "quotaSizeInBytes",
"name", "quotaUsageInBytes",
"email",
"profileImagePath",
"storageLabel", "storageLabel",
"externalPath", "externalPath",
"shouldChangePassword", "shouldChangePassword",
@ -9903,7 +9937,11 @@
"createdAt", "createdAt",
"deletedAt", "deletedAt",
"updatedAt", "updatedAt",
"oauthId" "oauthId",
"id",
"name",
"email",
"profileImagePath"
], ],
"type": "object" "type": "object"
}, },

View File

@ -1472,6 +1472,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto * @memberof CreateUserDto
*/ */
'password': string; 'password': string;
/**
*
* @type {number}
* @memberof CreateUserDto
*/
'quotaSizeInBytes'?: number | null;
/** /**
* *
* @type {string} * @type {string}
@ -2479,6 +2485,18 @@ export interface PartnerResponseDto {
* @memberof PartnerResponseDto * @memberof PartnerResponseDto
*/ */
'profileImagePath': string; 'profileImagePath': string;
/**
*
* @type {number}
* @memberof PartnerResponseDto
*/
'quotaSizeInBytes': number | null;
/**
*
* @type {number}
* @memberof PartnerResponseDto
*/
'quotaUsageInBytes': number;
/** /**
* *
* @type {boolean} * @type {boolean}
@ -4544,6 +4562,12 @@ export interface UpdateUserDto {
* @memberof UpdateUserDto * @memberof UpdateUserDto
*/ */
'password'?: string; 'password'?: string;
/**
*
* @type {number}
* @memberof UpdateUserDto
*/
'quotaSizeInBytes'?: number | null;
/** /**
* *
* @type {boolean} * @type {boolean}
@ -4571,6 +4595,12 @@ export interface UsageByUserDto {
* @memberof UsageByUserDto * @memberof UsageByUserDto
*/ */
'photos': number; 'photos': number;
/**
*
* @type {number}
* @memberof UsageByUserDto
*/
'quotaSizeInBytes': number | null;
/** /**
* *
* @type {number} * @type {number}
@ -4729,6 +4759,18 @@ export interface UserResponseDto {
* @memberof UserResponseDto * @memberof UserResponseDto
*/ */
'profileImagePath': string; 'profileImagePath': string;
/**
*
* @type {number}
* @memberof UserResponseDto
*/
'quotaSizeInBytes': number | null;
/**
*
* @type {number}
* @memberof UserResponseDto
*/
'quotaUsageInBytes': number;
/** /**
* *
* @type {boolean} * @type {boolean}

View File

@ -250,7 +250,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]); expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
}); });
it('should return album info for shared album', async () => { it('should return album info for shared album', async () => {
@ -259,7 +259,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(user2Albums[0]); expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] });
}); });
it('should return album info with assets when withoutAssets is undefined', async () => { it('should return album info with assets when withoutAssets is undefined', async () => {
@ -268,7 +268,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]); expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
}); });
it('should return album info without assets when withoutAssets is true', async () => { it('should return album info without assets when withoutAssets is true', async () => {

View File

@ -43,6 +43,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let assetRepository: IAssetRepository; let assetRepository: IAssetRepository;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user2: LoginResponseDto; let user2: LoginResponseDto;
let userWithQuota: LoginResponseDto;
let libraries: LibraryResponseDto[]; let libraries: LibraryResponseDto[];
let asset1: AssetResponseDto; let asset1: AssetResponseDto;
let asset2: AssetResponseDto; let asset2: AssetResponseDto;
@ -75,11 +76,13 @@ describe(`${AssetController.name} (e2e)`, () => {
await Promise.all([ await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1), api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2), api.userApi.create(server, admin.accessToken, userDto.user2),
api.userApi.create(server, admin.accessToken, userDto.userWithQuota),
]); ]);
[user1, user2] = await Promise.all([ [user1, user2, userWithQuota] = await Promise.all([
api.authApi.login(server, userDto.user1), api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2), api.authApi.login(server, userDto.user2),
api.authApi.login(server, userDto.userWithQuota),
]); ]);
const [user1Libraries, user2Libraries] = await Promise.all([ const [user1Libraries, user2Libraries] = await Promise.all([
@ -634,6 +637,46 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no asset.upload access')); expect(body).toEqual(errorStub.badRequest('Not found or no asset.upload access'));
}); });
it('should update the used quota', async () => {
const content = randomBytes(32);
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${userWithQuota.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', 'true')
.field('duration', '0:00:00.000000')
.attach('assetData', content, 'example.jpg');
expect(status).toBe(201);
expect(body).toEqual({ id: expect.any(String), duplicate: false });
const { body: user } = await request(server)
.get('/user/me')
.set('Authorization', `Bearer ${userWithQuota.accessToken}`);
expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 32 }));
});
it('should not upload an asset if it would exceed the quota', async () => {
const content = randomBytes(420);
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${userWithQuota.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', 'true')
.field('duration', '0:00:00.000000')
.attach('assetData', content, 'example.jpg');
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Quota has been exceeded!'));
});
}); });
describe('PUT /asset/:id', () => { describe('PUT /asset/:id', () => {

View File

@ -32,6 +32,8 @@ const adminSignupResponse = {
deletedAt: null, deletedAt: null,
oauthId: '', oauthId: '',
memoriesEnabled: true, memoriesEnabled: true,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
}; };
describe(`${AuthController.name} (e2e)`, () => { describe(`${AuthController.name} (e2e)`, () => {

View File

@ -128,6 +128,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
usage: 0, usage: 0,
usageByUser: [ usageByUser: [
{ {
quotaSizeInBytes: null,
photos: 0, photos: 0,
usage: 0, usage: 0,
userName: 'Immich Admin', userName: 'Immich Admin',
@ -135,6 +136,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
videos: 0, videos: 0,
}, },
{ {
quotaSizeInBytes: null,
photos: 0, photos: 0,
usage: 0, usage: 0,
userName: 'User 1', userName: 'User 1',

View File

@ -13,6 +13,7 @@ import {
newPartnerRepositoryMock, newPartnerRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
newUserRepositoryMock,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { Readable } from 'stream'; import { Readable } from 'stream';
@ -28,6 +29,7 @@ import {
IPartnerRepository, IPartnerRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
IUserRepository,
JobItem, JobItem,
TimeBucketSize, TimeBucketSize,
} from '../repositories'; } from '../repositories';
@ -67,6 +69,7 @@ const uploadFile = {
checksum: Buffer.from('checksum', 'utf8'), checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg', originalPath: 'upload/admin/image.jpeg',
originalName: 'image.jpeg', originalName: 'image.jpeg',
size: 1000,
}, },
}, },
filename: (fieldName: UploadFieldName, filename: string) => { filename: (fieldName: UploadFieldName, filename: string) => {
@ -79,6 +82,7 @@ const uploadFile = {
checksum: Buffer.from('checksum', 'utf8'), checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`, originalPath: `upload/admin/${filename}`,
originalName: filename, originalName: filename,
size: 1000,
}, },
}; };
}, },
@ -167,6 +171,7 @@ describe(AssetService.name, () => {
let cryptoMock: jest.Mocked<ICryptoRepository>; let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>; let communicationMock: jest.Mocked<ICommunicationRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let partnerMock: jest.Mocked<IPartnerRepository>; let partnerMock: jest.Mocked<IPartnerRepository>;
@ -182,6 +187,7 @@ describe(AssetService.name, () => {
cryptoMock = newCryptoRepositoryMock(); cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
partnerMock = newPartnerRepositoryMock(); partnerMock = newPartnerRepositoryMock();
@ -192,6 +198,7 @@ describe(AssetService.name, () => {
jobMock, jobMock,
configMock, configMock,
storageMock, storageMock,
userMock,
communicationMock, communicationMock,
partnerMock, partnerMock,
); );
@ -836,7 +843,7 @@ describe(AssetService.name, () => {
}); });
it('should remove faces', async () => { it('should remove faces', async () => {
const assetWithFace = { ...(assetStub.image as AssetEntity), faces: [faceStub.face1, faceStub.mergeFace1] }; const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
when(assetMock.getById).calledWith(assetWithFace.id).mockResolvedValue(assetWithFace); when(assetMock.getById).calledWith(assetWithFace.id).mockResolvedValue(assetWithFace);
@ -863,9 +870,7 @@ describe(AssetService.name, () => {
}); });
it('should update stack parent if asset has stack children', async () => { it('should update stack parent if asset has stack children', async () => {
when(assetMock.getById) when(assetMock.getById).calledWith(assetStub.primaryImage.id).mockResolvedValue(assetStub.primaryImage);
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
@ -878,9 +883,7 @@ describe(AssetService.name, () => {
}); });
it('should not schedule delete-files job for readonly assets', async () => { it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById) when(assetMock.getById).calledWith(assetStub.readOnly.id).mockResolvedValue(assetStub.readOnly);
.calledWith(assetStub.readOnly.id)
.mockResolvedValue(assetStub.readOnly as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.readOnly.id }); await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
@ -890,21 +893,17 @@ describe(AssetService.name, () => {
}); });
it('should not process assets from external library without fromExternal flag', async () => { it('should not process assets from external library without fromExternal flag', async () => {
when(assetMock.getById) when(assetMock.getById).calledWith(assetStub.external.id).mockResolvedValue(assetStub.external);
.calledWith(assetStub.external.id)
.mockResolvedValue(assetStub.external as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.external.id }); await sut.handleAssetDeletion({ id: assetStub.external.id });
expect(jobMock.queue).not.toBeCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toBeCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(assetMock.remove).not.toBeCalled(); expect(assetMock.remove).not.toHaveBeenCalled();
}); });
it('should process assets from external library with fromExternal flag', async () => { it('should process assets from external library with fromExternal flag', async () => {
when(assetMock.getById) when(assetMock.getById).calledWith(assetStub.external.id).mockResolvedValue(assetStub.external);
.calledWith(assetStub.external.id)
.mockResolvedValue(assetStub.external as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.external.id, fromExternal: true }); await sut.handleAssetDeletion({ id: assetStub.external.id, fromExternal: true });
@ -949,6 +948,13 @@ describe(AssetService.name, () => {
], ],
]); ]);
}); });
it('should update usage', async () => {
when(assetMock.getById).calledWith(assetStub.image.id).mockResolvedValue(assetStub.image);
await sut.handleAssetDeletion({ id: assetStub.image.id });
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
});
}); });
describe('run', () => { describe('run', () => {

View File

@ -20,6 +20,7 @@ import {
IPartnerRepository, IPartnerRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
IUserRepository,
ImmichReadStream, ImmichReadStream,
JobItem, JobItem,
TimeBucketOptions, TimeBucketOptions,
@ -75,6 +76,7 @@ export interface UploadFile {
checksum: Buffer; checksum: Buffer;
originalPath: string; originalPath: string;
originalName: string; originalName: string;
size: number;
} }
export class AssetService { export class AssetService {
@ -89,6 +91,7 @@ export class AssetService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) { ) {
@ -481,6 +484,7 @@ export class AssetService {
} }
await this.assetRepository.remove(asset); await this.assetRepository.remove(asset);
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
this.communicationRepository.send(ClientEvent.ASSET_DELETE, asset.ownerId, id); this.communicationRepository.send(ClientEvent.ASSET_DELETE, asset.ownerId, id);
// TODO refactor this to use cascades // TODO refactor this to use cascades

View File

@ -37,9 +37,10 @@ export enum JobName {
METADATA_EXTRACTION = 'metadata-extraction', METADATA_EXTRACTION = 'metadata-extraction',
LINK_LIVE_PHOTOS = 'link-live-photos', LINK_LIVE_PHOTOS = 'link-live-photos',
// user deletion // user
USER_DELETION = 'user-deletion', USER_DELETION = 'user-deletion',
USER_DELETE_CHECK = 'user-delete-check', USER_DELETE_CHECK = 'user-delete-check',
USER_SYNC_USAGE = 'user-sync-usage',
// asset // asset
ASSET_DELETION = 'asset-deletion', ASSET_DELETION = 'asset-deletion',
@ -95,6 +96,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK, [JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK,
[JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK, [JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
[JobName.PERSON_DELETE]: QueueName.BACKGROUND_TASK, [JobName.PERSON_DELETE]: QueueName.BACKGROUND_TASK,
[JobName.USER_SYNC_USAGE]: QueueName.BACKGROUND_TASK,
// conversion // conversion
[JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION, [JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,

View File

@ -60,6 +60,7 @@ describe(JobService.name, () => {
{ name: JobName.PERSON_CLEANUP }, { name: JobName.PERSON_CLEANUP },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },
]); ]);
}); });
}); });

View File

@ -164,6 +164,7 @@ export class JobService {
{ name: JobName.PERSON_CLEANUP }, { name: JobName.PERSON_CLEANUP },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },
]); ]);
} }

View File

@ -21,7 +21,9 @@ const responseDto = {
externalPath: null, externalPath: null,
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
inTimeline: true, inTimeline: true,
quotaUsageInBytes: 0,
}, },
user1: <PartnerResponseDto>{ user1: <PartnerResponseDto>{
email: 'immich@test.com', email: 'immich@test.com',
@ -39,6 +41,8 @@ const responseDto = {
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
inTimeline: true, inTimeline: true,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}, },
}; };

View File

@ -39,9 +39,10 @@ export type JobItem =
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob } | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_THUMBHASH_THUMBNAIL; data: IEntityJob } | { name: JobName.GENERATE_THUMBHASH_THUMBNAIL; data: IEntityJob }
// User Deletion // User
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
| { name: JobName.USER_DELETION; data: IEntityJob } | { name: JobName.USER_DELETION; data: IEntityJob }
| { name: JobName.USER_SYNC_USAGE; data?: IBaseJob }
// Storage Template // Storage Template
| { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob } | { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob }

View File

@ -10,6 +10,7 @@ export interface UserStatsQueryResponse {
photos: number; photos: number;
videos: number; videos: number;
usage: number; usage: number;
quotaSizeInBytes: number | null;
} }
export interface UserFindOptions { export interface UserFindOptions {
@ -32,4 +33,6 @@ export interface IUserRepository {
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>; update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>; delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>; restore(user: UserEntity): Promise<UserEntity>;
updateUsage(id: string, delta: number): Promise<void>;
syncUsage(): Promise<void>;
} }

View File

@ -45,6 +45,8 @@ export class UsageByUserDto {
videos!: number; videos!: number;
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
usage!: number; usage!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null;
} }
export class ServerStatsResponseDto { export class ServerStatsResponseDto {

View File

@ -220,6 +220,7 @@ describe(ServerInfoService.name, () => {
photos: 10, photos: 10,
videos: 11, videos: 11,
usage: 12345, usage: 12345,
quotaSizeInBytes: 0,
}, },
{ {
userId: 'user2', userId: 'user2',
@ -227,6 +228,7 @@ describe(ServerInfoService.name, () => {
photos: 10, photos: 10,
videos: 20, videos: 20,
usage: 123456, usage: 123456,
quotaSizeInBytes: 0,
}, },
{ {
userId: 'user3', userId: 'user3',
@ -234,6 +236,7 @@ describe(ServerInfoService.name, () => {
photos: 100, photos: 100,
videos: 0, videos: 0,
usage: 987654, usage: 987654,
quotaSizeInBytes: 0,
}, },
]); ]);
@ -244,6 +247,7 @@ describe(ServerInfoService.name, () => {
usageByUser: [ usageByUser: [
{ {
photos: 10, photos: 10,
quotaSizeInBytes: 0,
usage: 12345, usage: 12345,
userName: '1 User', userName: '1 User',
userId: 'user1', userId: 'user1',
@ -251,6 +255,7 @@ describe(ServerInfoService.name, () => {
}, },
{ {
photos: 10, photos: 10,
quotaSizeInBytes: 0,
usage: 123456, usage: 123456,
userName: '2 User', userName: '2 User',
userId: 'user2', userId: 'user2',
@ -258,6 +263,7 @@ describe(ServerInfoService.name, () => {
}, },
{ {
photos: 100, photos: 100,
quotaSizeInBytes: 0,
usage: 987654, usage: 987654,
userName: '3 User', userName: '3 User',
userId: 'user3', userId: 'user3',

View File

@ -118,6 +118,7 @@ export class ServerInfoService {
usage.photos = user.photos; usage.photos = user.photos;
usage.videos = user.videos; usage.videos = user.videos;
usage.usage = user.usage; usage.usage = user.usage;
usage.quotaSizeInBytes = user.quotaSizeInBytes;
serverStats.photos += usage.photos; serverStats.photos += usage.photos;
serverStats.videos += usage.videos; serverStats.videos += usage.videos;

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
import { Optional, toEmail, toSanitized } from '../../domain.util'; import { Optional, toEmail, toSanitized } from '../../domain.util';
export class CreateUserDto { export class CreateUserDto {
@ -27,6 +28,12 @@ export class CreateUserDto {
@Optional() @Optional()
@IsBoolean() @IsBoolean()
memoriesEnabled?: boolean; memoriesEnabled?: boolean;
@Optional({ nullable: true })
@IsNumber()
@IsPositive()
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
} }
export class CreateAdminDto { export class CreateAdminDto {

View File

@ -1,7 +1,7 @@
import { UserAvatarColor } from '@app/infra/entities'; import { UserAvatarColor } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator'; import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { Optional, toEmail, toSanitized } from '../../domain.util'; import { Optional, toEmail, toSanitized } from '../../domain.util';
export class UpdateUserDto { export class UpdateUserDto {
@ -50,4 +50,10 @@ export class UpdateUserDto {
@IsEnum(UserAvatarColor) @IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor; avatarColor?: UserAvatarColor;
@Optional({ nullable: true })
@IsNumber()
@IsPositive()
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
} }

View File

@ -33,6 +33,10 @@ export class UserResponseDto extends UserDto {
updatedAt!: Date; updatedAt!: Date;
oauthId!: string; oauthId!: string;
memoriesEnabled?: boolean; memoriesEnabled?: boolean;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaUsageInBytes!: number;
} }
export const mapSimpleUser = (entity: UserEntity): UserDto => { export const mapSimpleUser = (entity: UserEntity): UserDto => {
@ -57,5 +61,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,
oauthId: entity.oauthId, oauthId: entity.oauthId,
memoriesEnabled: entity.memoriesEnabled, memoriesEnabled: entity.memoriesEnabled,
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
}; };
} }

View File

@ -512,4 +512,11 @@ describe(UserService.name, () => {
expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
}); });
}); });
describe('handleUserSyncUsage', () => {
it('should sync usage', async () => {
await sut.handleUserSyncUsage();
expect(userMock.syncUsage).toHaveBeenCalledTimes(1);
});
});
}); });

View File

@ -127,6 +127,11 @@ export class UserService {
return { admin, password, provided: !!providedPassword }; return { admin, password, provided: !!providedPassword };
} }
async handleUserSyncUsage() {
await this.userRepository.syncUsage();
return true;
}
async handleUserDeleteCheck() { async handleUserDeleteCheck() {
const users = await this.userRepository.getDeletedUsers(); const users = await this.userRepository.getDeletedUsers();
await this.jobRepository.queueAll( await this.jobRepository.queueAll(

View File

@ -1,5 +1,5 @@
import { AssetCreate } from '@app/domain'; import { AssetCreate } from '@app/domain';
import { AssetEntity } from '@app/infra/entities'; import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { OptionalBetween } from '@app/infra/infra.utils'; import { OptionalBetween } from '@app/infra/infra.utils';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@ -23,6 +23,7 @@ export interface AssetOwnerCheck extends AssetCheck {
export interface IAssetRepository { export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>; get(id: string): Promise<AssetEntity | null>;
create(asset: AssetCreate): Promise<AssetEntity>; create(asset: AssetCreate): Promise<AssetEntity>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>; getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>; getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>; getById(assetId: string): Promise<AssetEntity>;
@ -38,7 +39,10 @@ export const IAssetRepository = 'IAssetRepository';
@Injectable() @Injectable()
export class AssetRepository implements IAssetRepository { export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {} constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> { getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
return this.assetRepository return this.assetRepository
@ -162,6 +166,10 @@ export class AssetRepository implements IAssetRepository {
return this.assetRepository.save(asset); return this.assetRepository.save(asset);
} }
async upsertExif(exif: Partial<ExifEntity>): Promise<void> {
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
}
/** /**
* Get assets by device's Id on the database * Get assets by device's Id on the database
* @param ownerId * @param ownerId

View File

@ -1,5 +1,6 @@
import { AuthDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/domain'; import { AuthDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/domain';
import { AssetEntity } from '@app/infra/entities'; import { AssetEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { parse } from 'node:path'; import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
@ -52,8 +53,15 @@ export class AssetCore {
isOffline: dto.isOffline ?? false, isOffline: dto.isOffline ?? false,
}); });
await this.repository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
return asset; return asset;
} }
static requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!');
}
}
} }

View File

@ -1,4 +1,4 @@
import { IJobRepository, ILibraryRepository, JobName } from '@app/domain'; import { IJobRepository, ILibraryRepository, IUserRepository, JobName } from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { import {
@ -9,6 +9,7 @@ import {
newAccessRepositoryMock, newAccessRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newLibraryRepositoryMock, newLibraryRepositoryMock,
newUserRepositoryMock,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
@ -87,11 +88,13 @@ describe('AssetService', () => {
let assetRepositoryMock: jest.Mocked<IAssetRepository>; let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>; let libraryMock: jest.Mocked<ILibraryRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
assetRepositoryMock = { assetRepositoryMock = {
get: jest.fn(), get: jest.fn(),
create: jest.fn(), create: jest.fn(),
upsertExif: jest.fn(),
getAllByUserId: jest.fn(), getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(), getAllByDeviceId: jest.fn(),
@ -107,8 +110,9 @@ describe('AssetService', () => {
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock(); libraryMock = newLibraryRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AssetService(accessMock, assetRepositoryMock, jobMock, libraryMock); sut = new AssetService(accessMock, assetRepositoryMock, jobMock, libraryMock, userMock);
when(assetRepositoryMock.get) when(assetRepositoryMock.get)
.calledWith(assetStub.livePhotoStillAsset.id) .calledWith(assetStub.livePhotoStillAsset.id)
@ -127,6 +131,7 @@ describe('AssetService', () => {
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg', originalName: 'asset_1.jpeg',
size: 42,
}; };
const dto = _getCreateAssetDto(); const dto = _getCreateAssetDto();
@ -136,6 +141,7 @@ describe('AssetService', () => {
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' }); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
expect(assetRepositoryMock.create).toHaveBeenCalled(); expect(assetRepositoryMock.create).toHaveBeenCalled();
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
}); });
it('should handle a duplicate', async () => { it('should handle a duplicate', async () => {
@ -145,6 +151,7 @@ describe('AssetService', () => {
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg', originalName: 'asset_1.jpeg',
size: 0,
}; };
const dto = _getCreateAssetDto(); const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], ''); const error = new QueryFailedError('', [], '');
@ -160,6 +167,7 @@ describe('AssetService', () => {
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] }, data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] },
}); });
expect(userMock.updateUsage).not.toHaveBeenCalled();
}); });
it('should handle a live photo', async () => { it('should handle a live photo', async () => {
@ -187,6 +195,7 @@ describe('AssetService', () => {
], ],
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }], [{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }],
]); ]);
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, 111);
}); });
}); });

View File

@ -8,6 +8,7 @@ import {
IJobRepository, IJobRepository,
ILibraryRepository, ILibraryRepository,
ImmichFileResponse, ImmichFileResponse,
IUserRepository,
JobName, JobName,
mapAsset, mapAsset,
mimeTypes, mimeTypes,
@ -49,6 +50,7 @@ export class AssetService {
@Inject(IAssetRepository) private _assetRepository: IAssetRepository, @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private libraryRepository: ILibraryRepository, @Inject(ILibraryRepository) private libraryRepository: ILibraryRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) { ) {
this.assetCore = new AssetCore(_assetRepository, jobRepository); this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
@ -73,6 +75,7 @@ export class AssetService {
try { try {
const libraryId = await this.getLibraryId(auth, dto.libraryId); const libraryId = await this.getLibraryId(auth, dto.libraryId);
await this.access.requirePermission(auth, Permission.ASSET_UPLOAD, libraryId); await this.access.requirePermission(auth, Permission.ASSET_UPLOAD, libraryId);
AssetCore.requireQuota(auth, file.size);
if (livePhotoFile) { if (livePhotoFile) {
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false, libraryId }; const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false, libraryId };
livePhotoAsset = await this.assetCore.create(auth, livePhotoDto, livePhotoFile); livePhotoAsset = await this.assetCore.create(auth, livePhotoDto, livePhotoFile);
@ -86,6 +89,8 @@ export class AssetService {
sidecarFile?.originalPath, sidecarFile?.originalPath,
); );
await this.userRepository.updateUsage(auth.user.id, (livePhotoFile?.size || 0) + file.size);
return { id: asset.id, duplicate: false }; return { id: asset.id, duplicate: false };
} catch (error: any) { } catch (error: any) {
// clean up files // clean up files

View File

@ -1,6 +1,6 @@
import { DomainModule } from '@app/domain'; import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra'; import { InfraModule } from '@app/infra';
import { AssetEntity } from '@app/infra/entities'; import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { Module, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleInit } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -40,7 +40,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
InfraModule, InfraModule,
DomainModule, DomainModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
], ],
controllers: [ controllers: [
ActivityController, ActivityController,

View File

@ -27,6 +27,7 @@ export function mapToUploadFile(file: ImmichFile): UploadFile {
checksum: file.checksum, checksum: file.checksum,
originalPath: file.path, originalPath: file.path,
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'), originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
size: file.size,
}; };
} }

View File

@ -75,4 +75,10 @@ export class UserEntity {
@OneToMany(() => AssetEntity, (asset) => asset.owner) @OneToMany(() => AssetEntity, (asset) => asset.owner)
assets!: AssetEntity[]; assets!: AssetEntity[];
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: number | null;
@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: number;
} }

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddQuotaColumnsToUser1704382918223 implements MigrationInterface {
name = 'AddQuotaColumnsToUser1704382918223'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "quotaSizeInBytes" bigint`);
await queryRunner.query(`ALTER TABLE "users" ADD "quotaUsageInBytes" bigint NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "quotaUsageInBytes"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "quotaSizeInBytes"`);
}
}

View File

@ -2,12 +2,15 @@ import { IUserRepository, UserFindOptions, UserListFilter, UserStatsQueryRespons
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm'; import { IsNull, Not, Repository } from 'typeorm';
import { UserEntity } from '../entities'; import { AssetEntity, UserEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util'; import { DummyValue, GenerateSql } from '../infra.util';
@Injectable() @Injectable()
export class UserRepository implements IUserRepository { export class UserRepository implements IUserRepository {
constructor(@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>) {} constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>,
) {}
async get(userId: string, options: UserFindOptions): Promise<UserEntity | null> { async get(userId: string, options: UserFindOptions): Promise<UserEntity | null> {
options = options || {}; options = options || {};
@ -91,6 +94,7 @@ export class UserRepository implements IUserRepository {
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos') .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos') .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage') .addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
.leftJoin('users.assets', 'assets') .leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif') .leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id') .groupBy('users.id')
@ -101,11 +105,32 @@ export class UserRepository implements IUserRepository {
stat.photos = Number(stat.photos); stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos); stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage); stat.usage = Number(stat.usage);
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
} }
return stats; return stats;
} }
async updateUsage(id: string, delta: number): Promise<void> {
await this.userRepository.increment({ id }, 'quotaUsageInBytes', delta);
}
async syncUsage() {
const subQuery = this.assetRepository
.createQueryBuilder('assets')
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)')
.leftJoin('assets.exifInfo', 'exif')
.where('assets.ownerId = users.id')
.withDeleted();
await this.userRepository
.createQueryBuilder('users')
.leftJoin('users.assets', 'assets')
.update()
.set({ quotaUsageInBytes: () => `(${subQuery.getQuery()})` })
.execute();
}
private async save(user: Partial<UserEntity>) { private async save(user: Partial<UserEntity>) {
const { id } = await this.userRepository.save(user); const { id } = await this.userRepository.save(user);
return this.userRepository.findOneByOrFail({ id }); return this.userRepository.findOneByOrFail({ id });

View File

@ -29,6 +29,8 @@ FROM
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -43,6 +45,8 @@ FROM
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -101,6 +105,8 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -114,7 +120,9 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled" "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
@ -155,6 +163,8 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -168,7 +178,9 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled" "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
@ -273,6 +285,8 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -298,7 +312,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled" "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id"
@ -342,6 +358,8 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -367,7 +385,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled" "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id"
@ -424,6 +444,8 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -449,7 +471,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled" "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id"
@ -498,7 +522,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled" "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"

View File

@ -22,7 +22,9 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."createdAt" AS "APIKeyEntity__APIKeyEntity_user_createdAt", "APIKeyEntity__APIKeyEntity_user"."createdAt" AS "APIKeyEntity__APIKeyEntity_user_createdAt",
"APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt", "APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt",
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled" "APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled",
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes"
FROM FROM
"api_keys" "APIKeyEntity" "api_keys" "APIKeyEntity"
LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId" LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId"

View File

@ -30,7 +30,9 @@ FROM
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled" "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM FROM
"libraries" "LibraryEntity" "libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@ -144,7 +146,9 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled" "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM FROM
"libraries" "LibraryEntity" "libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@ -188,7 +192,9 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled" "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM FROM
"libraries" "LibraryEntity" "libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@ -226,7 +232,9 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled" "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM FROM
"libraries" "LibraryEntity" "libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"

View File

@ -155,7 +155,9 @@ FROM
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled" "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
FROM FROM
"shared_links" "SharedLinkEntity" "shared_links" "SharedLinkEntity"
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id" LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
@ -257,7 +259,9 @@ SELECT
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled" "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
FROM FROM
"shared_links" "SharedLinkEntity" "shared_links" "SharedLinkEntity"
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id" LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
@ -309,7 +313,9 @@ FROM
"SharedLinkEntity__SharedLinkEntity_user"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_user_createdAt", "SharedLinkEntity__SharedLinkEntity_user"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_user_createdAt",
"SharedLinkEntity__SharedLinkEntity_user"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_user_deletedAt", "SharedLinkEntity__SharedLinkEntity_user"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_user_deletedAt",
"SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt", "SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt",
"SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled" "SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled",
"SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes",
"SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes"
FROM FROM
"shared_links" "SharedLinkEntity" "shared_links" "SharedLinkEntity"
LEFT JOIN "users" "SharedLinkEntity__SharedLinkEntity_user" ON "SharedLinkEntity__SharedLinkEntity_user"."id" = "SharedLinkEntity"."userId" LEFT JOIN "users" "SharedLinkEntity__SharedLinkEntity_user" ON "SharedLinkEntity__SharedLinkEntity_user"."id" = "SharedLinkEntity"."userId"

View File

@ -15,7 +15,9 @@ SELECT
"UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled" "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
FROM FROM
"users" "UserEntity" "users" "UserEntity"
WHERE WHERE
@ -60,7 +62,9 @@ SELECT
"user"."createdAt" AS "user_createdAt", "user"."createdAt" AS "user_createdAt",
"user"."deletedAt" AS "user_deletedAt", "user"."deletedAt" AS "user_deletedAt",
"user"."updatedAt" AS "user_updatedAt", "user"."updatedAt" AS "user_updatedAt",
"user"."memoriesEnabled" AS "user_memoriesEnabled" "user"."memoriesEnabled" AS "user_memoriesEnabled",
"user"."quotaSizeInBytes" AS "user_quotaSizeInBytes",
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes"
FROM FROM
"users" "user" "users" "user"
WHERE WHERE
@ -82,7 +86,9 @@ SELECT
"UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled" "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
FROM FROM
"users" "UserEntity" "users" "UserEntity"
WHERE WHERE
@ -106,7 +112,9 @@ SELECT
"UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled" "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
FROM FROM
"users" "UserEntity" "users" "UserEntity"
WHERE WHERE
@ -119,6 +127,7 @@ LIMIT
SELECT SELECT
"users"."id" AS "userId", "users"."id" AS "userId",
"users"."name" AS "userName", "users"."name" AS "userName",
"users"."quotaSizeInBytes" AS "quotaSizeInBytes",
COUNT("assets"."id") FILTER ( COUNT("assets"."id") FILTER (
WHERE WHERE
"assets"."type" = 'IMAGE' "assets"."type" = 'IMAGE'

View File

@ -25,7 +25,9 @@ FROM
"UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt", "UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt",
"UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt", "UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt",
"UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt", "UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt",
"UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled" "UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled",
"UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes",
"UserTokenEntity__UserTokenEntity_user"."quotaUsageInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaUsageInBytes"
FROM FROM
"user_token" "UserTokenEntity" "user_token" "UserTokenEntity"
LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId" LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId"

View File

@ -45,6 +45,7 @@ export class AppService {
[JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(), [JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(), [JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data), [JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(),
[JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data), [JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data), [JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(), [JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),

View File

@ -4,11 +4,13 @@ export const fileStub = {
originalPath: 'fake_path/asset_1.jpeg', originalPath: 'fake_path/asset_1.jpeg',
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg', originalName: 'asset_1.jpeg',
size: 42,
}), }),
livePhotoMotion: Object.freeze({ livePhotoMotion: Object.freeze({
uuid: 'random-uuid', uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.mp4', originalPath: 'fake_path/asset_1.mp4',
checksum: Buffer.from('live photo file hash', 'utf8'), checksum: Buffer.from('live photo file hash', 'utf8'),
originalName: 'asset_1.mp4', originalName: 'asset_1.mp4',
size: 69,
}), }),
}; };

View File

@ -17,6 +17,12 @@ export const userDto = {
password: 'Password123', password: 'Password123',
name: 'User 3', name: 'User 3',
}, },
userWithQuota: {
email: 'quota-user@immich.app',
password: 'Password123',
name: 'User with quota',
quotaSizeInBytes: 42,
},
}; };
export const userStub = { export const userStub = {
@ -36,6 +42,8 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}), }),
user1: Object.freeze<UserEntity>({ user1: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
@ -53,6 +61,8 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}), }),
user2: Object.freeze<UserEntity>({ user2: Object.freeze<UserEntity>({
...authStub.user2.user, ...authStub.user2.user,
@ -70,6 +80,8 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}), }),
storageLabel: Object.freeze<UserEntity>({ storageLabel: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
@ -87,6 +99,8 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}), }),
externalPath1: Object.freeze<UserEntity>({ externalPath1: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
@ -104,6 +118,8 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}), }),
externalPath2: Object.freeze<UserEntity>({ externalPath2: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
@ -121,6 +137,8 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}), }),
profilePath: Object.freeze<UserEntity>({ profilePath: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
@ -138,5 +156,7 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY, avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}), }),
}; };

View File

@ -19,5 +19,7 @@ export const newUserRepositoryMock = (reset = true): jest.Mocked<IUserRepository
getDeletedUsers: jest.fn(), getDeletedUsers: jest.fn(),
restore: jest.fn(), restore: jest.fn(),
hasAdmin: jest.fn(), hasAdmin: jest.fn(),
updateUsage: jest.fn(),
syncUsage: jest.fn(),
}; };
}; };

View File

@ -3,7 +3,7 @@
import type { ServerStatsResponseDto } from '@api'; import type { ServerStatsResponseDto } from '@api';
import { asByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units'; import { asByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units';
import StatsCard from './stats-card.svelte'; import StatsCard from './stats-card.svelte';
import { mdiCameraIris, mdiMemory, mdiPlayCircle } from '@mdi/js'; import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
export let stats: ServerStatsResponseDto = { export let stats: ServerStatsResponseDto = {
@ -32,7 +32,7 @@
<div class="mt-5 hidden justify-between lg:flex"> <div class="mt-5 hidden justify-between lg:flex">
<StatsCard icon={mdiCameraIris} title="PHOTOS" value={stats.photos} /> <StatsCard icon={mdiCameraIris} title="PHOTOS" value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title="VIDEOS" value={stats.videos} /> <StatsCard icon={mdiPlayCircle} title="VIDEOS" value={stats.videos} />
<StatsCard icon={mdiMemory} title="STORAGE" value={statsUsage} unit={statsUsageUnit} /> <StatsCard icon={mdiChartPie} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
</div> </div>
<div class="mt-5 flex lg:hidden"> <div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray"> <div class="flex flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
@ -62,7 +62,7 @@
</div> </div>
<div class="flex flex-wrap gap-x-7"> <div class="flex flex-wrap gap-x-7">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary"> <div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiMemory} size="25" /> <Icon path={mdiChartPie} size="25" />
<p>STORAGE</p> <p>STORAGE</p>
</div> </div>
@ -87,7 +87,7 @@
<th class="w-1/4 text-center text-sm font-medium">User</th> <th class="w-1/4 text-center text-sm font-medium">User</th>
<th class="w-1/4 text-center text-sm font-medium">Photos</th> <th class="w-1/4 text-center text-sm font-medium">Photos</th>
<th class="w-1/4 text-center text-sm font-medium">Videos</th> <th class="w-1/4 text-center text-sm font-medium">Videos</th>
<th class="w-1/4 text-center text-sm font-medium">Size</th> <th class="w-1/4 text-center text-sm font-medium">Usage</th>
</tr> </tr>
</thead> </thead>
<tbody <tbody
@ -100,7 +100,16 @@
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td> <td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.photos.toLocaleString($locale)}</td> <td class="w-1/4 text-ellipsis px-2 text-sm">{user.photos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.videos.toLocaleString($locale)}</td> <td class="w-1/4 text-ellipsis px-2 text-sm">{user.videos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{asByteUnitString(user.usage, $locale)}</td> <td class="w-1/4 text-ellipsis px-2 text-sm">
{asByteUnitString(user.usage, $locale, 0)}
<span class="text-immich-primary dark:text-immich-dark-primary">
{#if user.quotaSizeInBytes}
({((user.usage / user.quotaSizeInBytes) * 100).toFixed(0)}%)
{:else}
(Unlimited)
{/if}
</span>
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@ -4,6 +4,7 @@
import ImmichLogo from '../shared-components/immich-logo.svelte'; import ImmichLogo from '../shared-components/immich-logo.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification'; import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import { convertToBytes } from '$lib/utils/byte-converter';
let error: string; let error: string;
let success: string; let success: string;
@ -42,6 +43,7 @@
const email = form.get('email'); const email = form.get('email');
const password = form.get('password'); const password = form.get('password');
const name = form.get('name'); const name = form.get('name');
const quotaSize = form.get('quotaSize');
try { try {
const { status } = await api.userApi.createUser({ const { status } = await api.userApi.createUser({
@ -49,6 +51,7 @@
email: String(email), email: String(email),
password: String(password), password: String(password),
name: String(name), name: String(name),
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
}, },
}); });
@ -117,6 +120,11 @@
<input class="immich-form-input" id="name" name="name" type="text" required /> <input class="immich-form-input" id="name" name="name" type="text" required />
</div> </div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="quotaSize">Quota Size (GB)</label>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" />
</div>
{#if error} {#if error}
<p class="ml-4 text-sm text-red-400">{error}</p> <p class="ml-4 text-sm text-red-400">{error}</p>
{/if} {/if}

View File

@ -4,11 +4,12 @@
import { notificationController, NotificationType } from '../shared-components/notification/notification'; import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { mdiAccountEditOutline, mdiClose } from '@mdi/js'; import { mdiAccountEditOutline, mdiClose } from '@mdi/js';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { handleError } from '$lib/utils/handle-error';
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
export let user: UserResponseDto; export let user: UserResponseDto;
export let canResetPassword = true; export let canResetPassword = true;
@ -24,6 +25,8 @@
editSuccess: void; editSuccess: void;
}>(); }>();
let quotaSize = user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, 'GiB') : null;
const editUser = async () => { const editUser = async () => {
try { try {
const { id, email, name, storageLabel, externalPath } = user; const { id, email, name, storageLabel, externalPath } = user;
@ -34,6 +37,7 @@
name, name,
storageLabel: storageLabel || '', storageLabel: storageLabel || '',
externalPath: externalPath || '', externalPath: externalPath || '',
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
}, },
}); });
@ -97,6 +101,11 @@
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} /> <input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
</div> </div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="quotaSize">Quota Size (GB)</label>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
</div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label> <label class="immich-form-label" for="storage-label">Storage Label</label>
<input <input

View File

@ -1,19 +1,43 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import Icon from '$lib/components/elements/icon.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { websocketStore } from '$lib/stores/websocket'; import { websocketStore } from '$lib/stores/websocket';
import { api } from '@api'; import { UserResponseDto, api } from '@api';
import { onDestroy, onMount } from 'svelte'; import { onMount } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { asByteUnitString } from '../../utils/byte-units'; import { asByteUnitString } from '../../utils/byte-units';
import LoadingSpinner from './loading-spinner.svelte'; import LoadingSpinner from './loading-spinner.svelte';
import { mdiCloud, mdiDns } from '@mdi/js'; import { mdiChartPie, mdiDns } from '@mdi/js';
import { serverInfoStore } from '$lib/stores/server-info.store'; import { serverInfoStore } from '$lib/stores/server-info.store';
const { serverVersion, connected } = websocketStore; const { serverVersion, connected } = websocketStore;
let userInfo: UserResponseDto;
let usageClasses = '';
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null; $: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
$: usedPercentage = Math.round(($serverInfoStore?.diskUseRaw / $serverInfoStore?.diskSizeRaw) * 100); $: hasQuota = userInfo?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? userInfo?.quotaSizeInBytes : $serverInfoStore.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? userInfo?.quotaUsageInBytes : $serverInfoStore.diskUseRaw) || 0;
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
const onUpdate = () => {
usedPercentage = 81;
usageClasses = getUsageClass();
};
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
};
$: userInfo && onUpdate();
onMount(async () => { onMount(async () => {
await refresh(); await refresh();
@ -21,39 +45,33 @@
const refresh = async () => { const refresh = async () => {
try { try {
const { data } = await api.serverInfoApi.getServerInfo(); [$serverInfoStore, userInfo] = await Promise.all([
$serverInfoStore = data; api.serverInfoApi.getServerInfo().then(({ data }) => data),
api.userApi.getMyUserInfo().then(({ data }) => data),
]);
} catch (e) { } catch (e) {
console.log('Error [StatusBox] [onMount]'); console.log('Error [StatusBox] [onMount]');
} }
}; };
let interval: number;
if (browser) {
interval = window.setInterval(() => refresh(), 10_000);
}
onDestroy(() => clearInterval(interval));
</script> </script>
<div class="dark:text-immich-dark-fg"> <div class="dark:text-immich-dark-fg">
<div class="storage-status grid grid-cols-[64px_auto]"> <div
class="storage-status grid grid-cols-[64px_auto]"
title="Used {asByteUnitString(usedBytes, $locale, 3)} of {asByteUnitString(availableBytes, $locale, 3)}"
>
<div class="pb-[2.15rem] pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0"> <div class="pb-[2.15rem] pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiCloud} size={'24'} /> <Icon path={mdiChartPie} size="24" />
</div> </div>
<div class="hidden group-hover:sm:block md:block"> <div class="hidden group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p> <p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p>
{#if $serverInfoStore} {#if $serverInfoStore}
<div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700"> <div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<!-- style={`width: ${$downloadAssets[fileName]}%`} --> <div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
<div
class="h-[7px] rounded-full bg-immich-primary dark:bg-immich-dark-primary"
style="width: {usedPercentage}%"
/>
</div> </div>
<p class="text-xs"> <p class="text-xs">
{asByteUnitString($serverInfoStore?.diskUseRaw, $locale)} of {asByteUnitString(usedBytes, $locale)} of
{asByteUnitString($serverInfoStore?.diskSizeRaw, $locale)} used {asByteUnitString(availableBytes, $locale)} used
</p> </p>
{:else} {:else}
<div class="mt-2"> <div class="mt-2">
@ -67,7 +85,7 @@
</div> </div>
<div class="server-status grid grid-cols-[64px_auto]"> <div class="server-status grid grid-cols-[64px_auto]">
<div class="pb-11 pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0"> <div class="pb-11 pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiDns} size={'24'} /> <Icon path={mdiDns} size="26" />
</div> </div>
<div class="hidden text-xs group-hover:sm:block md:block"> <div class="hidden text-xs group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Server</p> <p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Server</p>

View File

@ -0,0 +1,37 @@
/**
* Convert to bytes from on a specified unit.
*
* * `1, 'GiB'`, returns `1073741824` bytes
*
* @param size value to be converted
* @param unit unit to convert from
* @returns bytes (number)
*/
export function convertToBytes(size: number, unit: string): number {
let bytes = 0;
if (unit === 'GiB') {
bytes = size * 1073741824;
}
return bytes;
}
/**
* Convert from bytes to a specified unit.
*
* * `11073741824, 'GiB'`, returns `1` GiB
*
* @param bytes value to be converted
* @param unit unit to convert to
* @returns bytes (number)
*/
export function convertFromBytes(bytes: number, unit: string): number {
let size = 0;
if (unit === 'GiB') {
size = bytes / 1073741824;
}
return size;
}

View File

@ -1,9 +1,9 @@
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { api, AssetFileUploadResponseDto } from '@api'; import { api, AssetFileUploadResponseDto } from '@api';
import { notificationController, NotificationType } from './../components/shared-components/notification/notification';
import { UploadState } from '$lib/models/upload-asset'; import { UploadState } from '$lib/models/upload-asset';
import { ExecutorQueue } from '$lib/utils/executor-queue'; import { ExecutorQueue } from '$lib/utils/executor-queue';
import { getServerErrorMessage, handleError } from './handle-error';
let _extensions: string[]; let _extensions: string[];
@ -115,26 +115,10 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
return res.id; return res.id;
} }
}) })
.catch((reason) => { .catch(async (error) => {
console.log('error uploading file ', reason); await handleError(error, 'Unable to upload file');
const reason = (await getServerErrorMessage(error)) || error;
uploadAssetsStore.updateAsset(deviceAssetId, { state: UploadState.ERROR, error: reason }); uploadAssetsStore.updateAsset(deviceAssetId, { state: UploadState.ERROR, error: reason });
handleUploadError(asset, JSON.stringify(reason));
return undefined; return undefined;
}); });
} }
function handleUploadError(asset: File, respBody = '{}', extraMessage?: string) {
try {
const res = JSON.parse(respBody);
const extraMsg = res ? ' ' + res?.message : '';
const messageSuffix = extraMessage !== undefined ? ` ${extraMessage}` : '';
notificationController.show({
type: NotificationType.Error,
message: `Cannot upload file ${asset.name} ${extraMsg}${messageSuffix}`,
timeout: 5000,
});
} catch (e) {
console.error('ERROR parsing data JSON in handleUploadError');
}
}

View File

@ -17,4 +17,6 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
memoriesEnabled: true, memoriesEnabled: true,
oauthId: '', oauthId: '',
avatarColor: UserAvatarColor.Primary, avatarColor: UserAvatarColor.Primary,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
}); });