chore!: migrate album owner to album_user (#27467)

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Daniel Dietzler 2026-04-22 16:52:23 +02:00 committed by GitHub
parent dfacde5af8
commit 4bfb8b36c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 14750 additions and 1104 deletions

View File

@ -154,23 +154,31 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedEditorUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
shared: true,
}),
]),
@ -184,23 +192,31 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedEditorUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1NotShared,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: false,
}),
]),
@ -216,23 +232,31 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedEditorUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedLink,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: true,
}),
expect.objectContaining({
ownerId: user2.userId,
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
shared: true,
}),
]),
@ -248,8 +272,10 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user1.userId,
albumName: user1NotShared,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) },
]),
shared: false,
}),
]),
@ -286,13 +312,17 @@ describe('/albums', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user4.userId,
albumName: user4DeletedAsset,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
]),
shared: false,
}),
expect.objectContaining({
ownerId: user4.userId,
albumName: user4Empty,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user4.userId }) },
]),
shared: false,
}),
]),
@ -362,16 +392,17 @@ describe('/albums', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
});
expect(body).toEqual(
expect.objectContaining({
contributorCounts: [{ userId: user1.userId, assetCount: 1 }],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
}),
);
});
});
@ -397,15 +428,13 @@ describe('/albums', () => {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
ownerId: user1.userId,
albumName: 'New album',
description: '',
albumThumbnailAssetId: null,
shared: false,
albumUsers: [],
albumUsers: [{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user1.userId }) }],
hasSharedLink: false,
assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true,
order: AssetOrder.Desc,
});
@ -621,11 +650,11 @@ describe('/albums', () => {
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
albumUsers: [
albumUsers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({ id: user2.userId }),
}),
],
]),
}),
);
});
@ -637,7 +666,7 @@ describe('/albums', () => {
.send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
expect(body).toEqual(errorDto.badRequest('User already added'));
});
it('should not be able to add existing user to shared album', async () => {
@ -663,7 +692,7 @@ describe('/albums', () => {
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
});
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
const { status } = await request(app)
.put(`/albums/${album.id}/user/${user2.userId}`)
@ -678,7 +707,10 @@ describe('/albums', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(body).toEqual(
expect.objectContaining({
albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })],
albumUsers: [
expect.objectContaining({ role: AlbumUserRole.Owner }),
expect.objectContaining({ role: AlbumUserRole.Editor }),
],
}),
);
});
@ -689,7 +721,7 @@ describe('/albums', () => {
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
});
expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer);
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Viewer);
const { status, body } = await request(app)
.put(`/albums/${album.id}/user/${user2.userId}`)

View File

@ -3,6 +3,7 @@
*/
import {
AlbumUserRole,
AssetTypeEnum,
AssetVisibility,
UserAvatarColor,
@ -420,9 +421,7 @@ export function getAlbum(
albumThumbnailAssetId: album.thumbnailAssetId,
createdAt: album.createdAt,
updatedAt: album.updatedAt,
ownerId: albumOwner.id,
owner: albumOwner,
albumUsers: [], // Empty array for non-shared album
albumUsers: [{ user: albumOwner, role: AlbumUserRole.Owner }],
shared: false,
hasSharedLink: false,
isActivityEnabled: true,

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ enum AlbumUserRole {
// do not change this order!
editor,
viewer,
owner,
}
// Model for an album stored in the server

View File

@ -7,8 +7,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
@ -28,10 +28,6 @@ class RemoteAlbumService {
return _repository.get(albumId);
}
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
return _repository.getByName(albumName, ownerId);
}
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
AlbumSortMode sortMode, {
@ -86,8 +82,18 @@ class RemoteAlbumService {
return filtered;
}
Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async {
final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds);
Future<RemoteAlbum> createAlbum({
required String title,
required UserDto owner,
required List<String> assetIds,
String? description,
}) async {
final album = await _albumApiRepository.createDriftAlbum(
title,
owner,
description: description,
assetIds: assetIds,
);
await _repository.create(album, assetIds);
return album;
@ -101,8 +107,10 @@ class RemoteAlbumService {
bool? isActivityEnabled,
AlbumAssetOrder? order,
}) async {
final owner = await _repository.getOwner(albumId);
final updatedAlbum = await _albumApiRepository.updateAlbum(
albumId,
owner,
name: name,
description: description,
thumbnailAssetId: thumbnailAssetId,

View File

@ -1,8 +1,11 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
@ -12,6 +15,7 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
),
);
@ -19,8 +23,14 @@ class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository);
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._storeService,
);
final _log = Logger("SyncLinkedAlbumService");
@ -103,7 +113,11 @@ class SyncLinkedAlbumService {
/// Creates a new remote album and links it to the local album
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
dPrint(() => "Creating new remote album for local album: ${localAlbum.name}");
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []);
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(
localAlbum.name,
_storeService.get(StoreKey.currentUser),
assetIds: [],
);
await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
}

View File

@ -225,6 +225,8 @@ class SyncStreamService {
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.albumV1:
return _syncStreamRepository.updateAlbumsV1(data.cast());
case SyncEntityType.albumV2:
return _syncStreamRepository.updateAlbumsV2(data.cast());
case SyncEntityType.albumDeleteV1:
return _syncStreamRepository.deleteAlbumsV1(data.cast());
case SyncEntityType.albumUserV1:

View File

@ -1,10 +1,8 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)')
class RemoteAlbumEntity extends Table with DriftDefaultsMixin {
const RemoteAlbumEntity();
@ -18,8 +16,6 @@ class RemoteAlbumEntity extends Table with DriftDefaultsMixin {
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get thumbnailAssetId =>
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.setNull).nullable()();

View File

@ -7,11 +7,9 @@ import 'package:immich_mobile/domain/models/album/album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'
as i3;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i7;
typedef $$RemoteAlbumEntityTableCreateCompanionBuilder =
i1.RemoteAlbumEntityCompanion Function({
@ -20,7 +18,6 @@ typedef $$RemoteAlbumEntityTableCreateCompanionBuilder =
i0.Value<String> description,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
required String ownerId,
i0.Value<String?> thumbnailAssetId,
i0.Value<bool> isActivityEnabled,
required i2.AlbumAssetOrder order,
@ -32,7 +29,6 @@ typedef $$RemoteAlbumEntityTableUpdateCompanionBuilder =
i0.Value<String> description,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
i0.Value<String> ownerId,
i0.Value<String?> thumbnailAssetId,
i0.Value<bool> isActivityEnabled,
i0.Value<i2.AlbumAssetOrder> order,
@ -51,42 +47,10 @@ final class $$RemoteAlbumEntityTableReferences
super.$_typedResult,
);
static i5.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumEntityTable>('remote_album_entity')
.ownerId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$UserEntityTable>('user_entity').id,
),
);
i5.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;
final manager = i5
.$$UserEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i5.$UserEntityTable>('user_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_ownerIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
static i7.$RemoteAssetEntityTable _thumbnailAssetIdTable(
static i5.$RemoteAssetEntityTable _thumbnailAssetIdTable(
i0.GeneratedDatabase db,
) => i6.ReadDatabaseContainer(db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity')
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
@ -94,19 +58,19 @@ final class $$RemoteAlbumEntityTableReferences
.thumbnailAssetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity').id,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i7.$$RemoteAssetEntityTableProcessedTableManager? get thumbnailAssetId {
i5.$$RemoteAssetEntityTableProcessedTableManager? get thumbnailAssetId {
final $_column = $_itemColumn<String>('thumbnail_asset_id');
if ($_column == null) return null;
final manager = i7
final manager = i5
.$$RemoteAssetEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_thumbnailAssetIdTable($_db));
@ -162,51 +126,24 @@ class $$RemoteAlbumEntityTableFilterComposer
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i5.$$UserEntityTableFilterComposer get ownerId {
final i5.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$UserEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
i7.$$RemoteAssetEntityTableFilterComposer get thumbnailAssetId {
final i7.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
i5.$$RemoteAssetEntityTableFilterComposer get thumbnailAssetId {
final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailAssetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i7.$$RemoteAssetEntityTableFilterComposer(
}) => i5.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@ -261,52 +198,25 @@ class $$RemoteAlbumEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column),
);
i5.$$UserEntityTableOrderingComposer get ownerId {
final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$UserEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
i7.$$RemoteAssetEntityTableOrderingComposer get thumbnailAssetId {
final i7.$$RemoteAssetEntityTableOrderingComposer composer =
i5.$$RemoteAssetEntityTableOrderingComposer get thumbnailAssetId {
final i5.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailAssetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i7.$$RemoteAssetEntityTableOrderingComposer(
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@ -351,52 +261,25 @@ class $$RemoteAlbumEntityTableAnnotationComposer
i0.GeneratedColumnWithTypeConverter<i2.AlbumAssetOrder, int> get order =>
$composableBuilder(column: $table.order, builder: (column) => column);
i5.$$UserEntityTableAnnotationComposer get ownerId {
final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$UserEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
i7.$$RemoteAssetEntityTableAnnotationComposer get thumbnailAssetId {
final i7.$$RemoteAssetEntityTableAnnotationComposer composer =
i5.$$RemoteAssetEntityTableAnnotationComposer get thumbnailAssetId {
final i5.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailAssetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i7.$$RemoteAssetEntityTableAnnotationComposer(
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@ -420,7 +303,7 @@ class $$RemoteAlbumEntityTableTableManager
$$RemoteAlbumEntityTableUpdateCompanionBuilder,
(i1.RemoteAlbumEntityData, i1.$$RemoteAlbumEntityTableReferences),
i1.RemoteAlbumEntityData,
i0.PrefetchHooks Function({bool ownerId, bool thumbnailAssetId})
i0.PrefetchHooks Function({bool thumbnailAssetId})
> {
$$RemoteAlbumEntityTableTableManager(
i0.GeneratedDatabase db,
@ -445,7 +328,6 @@ class $$RemoteAlbumEntityTableTableManager
i0.Value<String> description = const i0.Value.absent(),
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
i0.Value<String> ownerId = const i0.Value.absent(),
i0.Value<String?> thumbnailAssetId = const i0.Value.absent(),
i0.Value<bool> isActivityEnabled = const i0.Value.absent(),
i0.Value<i2.AlbumAssetOrder> order = const i0.Value.absent(),
@ -455,7 +337,6 @@ class $$RemoteAlbumEntityTableTableManager
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
@ -467,7 +348,6 @@ class $$RemoteAlbumEntityTableTableManager
i0.Value<String> description = const i0.Value.absent(),
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
required String ownerId,
i0.Value<String?> thumbnailAssetId = const i0.Value.absent(),
i0.Value<bool> isActivityEnabled = const i0.Value.absent(),
required i2.AlbumAssetOrder order,
@ -477,7 +357,6 @@ class $$RemoteAlbumEntityTableTableManager
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
@ -490,7 +369,7 @@ class $$RemoteAlbumEntityTableTableManager
),
)
.toList(),
prefetchHooksCallback: ({ownerId = false, thumbnailAssetId = false}) {
prefetchHooksCallback: ({thumbnailAssetId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
@ -510,21 +389,6 @@ class $$RemoteAlbumEntityTableTableManager
dynamic
>
>(state) {
if (ownerId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.ownerId,
referencedTable: i1
.$$RemoteAlbumEntityTableReferences
._ownerIdTable(db),
referencedColumn: i1
.$$RemoteAlbumEntityTableReferences
._ownerIdTable(db)
.id,
)
as T;
}
if (thumbnailAssetId) {
state =
state.withJoin(
@ -564,12 +428,8 @@ typedef $$RemoteAlbumEntityTableProcessedTableManager =
$$RemoteAlbumEntityTableUpdateCompanionBuilder,
(i1.RemoteAlbumEntityData, i1.$$RemoteAlbumEntityTableReferences),
i1.RemoteAlbumEntityData,
i0.PrefetchHooks Function({bool ownerId, bool thumbnailAssetId})
i0.PrefetchHooks Function({bool thumbnailAssetId})
>;
i0.Index get idxRemoteAlbumOwnerId => i0.Index(
'idx_remote_album_owner_id',
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
);
class $RemoteAlbumEntityTable extends i3.RemoteAlbumEntity
with i0.TableInfo<$RemoteAlbumEntityTable, i1.RemoteAlbumEntityData> {
@ -636,20 +496,6 @@ class $RemoteAlbumEntityTable extends i3.RemoteAlbumEntity
requiredDuringInsert: false,
defaultValue: i4.currentDateAndTime,
);
static const i0.VerificationMeta _ownerIdMeta = const i0.VerificationMeta(
'ownerId',
);
@override
late final i0.GeneratedColumn<String> ownerId = i0.GeneratedColumn<String>(
'owner_id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE',
),
);
static const i0.VerificationMeta _thumbnailAssetIdMeta =
const i0.VerificationMeta('thumbnailAssetId');
@override
@ -698,7 +544,6 @@ class $RemoteAlbumEntityTable extends i3.RemoteAlbumEntity
description,
createdAt,
updatedAt,
ownerId,
thumbnailAssetId,
isActivityEnabled,
order,
@ -749,14 +594,6 @@ class $RemoteAlbumEntityTable extends i3.RemoteAlbumEntity
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
}
if (data.containsKey('owner_id')) {
context.handle(
_ownerIdMeta,
ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta),
);
} else if (isInserting) {
context.missing(_ownerIdMeta);
}
if (data.containsKey('thumbnail_asset_id')) {
context.handle(
_thumbnailAssetIdMeta,
@ -807,10 +644,6 @@ class $RemoteAlbumEntityTable extends i3.RemoteAlbumEntity
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
ownerId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}owner_id'],
)!,
thumbnailAssetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}thumbnail_asset_id'],
@ -850,7 +683,6 @@ class RemoteAlbumEntityData extends i0.DataClass
final String description;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String? thumbnailAssetId;
final bool isActivityEnabled;
final i2.AlbumAssetOrder order;
@ -860,7 +692,6 @@ class RemoteAlbumEntityData extends i0.DataClass
required this.description,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
this.thumbnailAssetId,
required this.isActivityEnabled,
required this.order,
@ -873,7 +704,6 @@ class RemoteAlbumEntityData extends i0.DataClass
map['description'] = i0.Variable<String>(description);
map['created_at'] = i0.Variable<DateTime>(createdAt);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
map['owner_id'] = i0.Variable<String>(ownerId);
if (!nullToAbsent || thumbnailAssetId != null) {
map['thumbnail_asset_id'] = i0.Variable<String>(thumbnailAssetId);
}
@ -897,7 +727,6 @@ class RemoteAlbumEntityData extends i0.DataClass
description: serializer.fromJson<String>(json['description']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
ownerId: serializer.fromJson<String>(json['ownerId']),
thumbnailAssetId: serializer.fromJson<String?>(json['thumbnailAssetId']),
isActivityEnabled: serializer.fromJson<bool>(json['isActivityEnabled']),
order: i1.$RemoteAlbumEntityTable.$converterorder.fromJson(
@ -914,7 +743,6 @@ class RemoteAlbumEntityData extends i0.DataClass
'description': serializer.toJson<String>(description),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
'ownerId': serializer.toJson<String>(ownerId),
'thumbnailAssetId': serializer.toJson<String?>(thumbnailAssetId),
'isActivityEnabled': serializer.toJson<bool>(isActivityEnabled),
'order': serializer.toJson<int>(
@ -929,7 +757,6 @@ class RemoteAlbumEntityData extends i0.DataClass
String? description,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
i0.Value<String?> thumbnailAssetId = const i0.Value.absent(),
bool? isActivityEnabled,
i2.AlbumAssetOrder? order,
@ -939,7 +766,6 @@ class RemoteAlbumEntityData extends i0.DataClass
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
thumbnailAssetId: thumbnailAssetId.present
? thumbnailAssetId.value
: this.thumbnailAssetId,
@ -955,7 +781,6 @@ class RemoteAlbumEntityData extends i0.DataClass
: this.description,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId,
thumbnailAssetId: data.thumbnailAssetId.present
? data.thumbnailAssetId.value
: this.thumbnailAssetId,
@ -974,7 +799,6 @@ class RemoteAlbumEntityData extends i0.DataClass
..write('description: $description, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('thumbnailAssetId: $thumbnailAssetId, ')
..write('isActivityEnabled: $isActivityEnabled, ')
..write('order: $order')
@ -989,7 +813,6 @@ class RemoteAlbumEntityData extends i0.DataClass
description,
createdAt,
updatedAt,
ownerId,
thumbnailAssetId,
isActivityEnabled,
order,
@ -1003,7 +826,6 @@ class RemoteAlbumEntityData extends i0.DataClass
other.description == this.description &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt &&
other.ownerId == this.ownerId &&
other.thumbnailAssetId == this.thumbnailAssetId &&
other.isActivityEnabled == this.isActivityEnabled &&
other.order == this.order);
@ -1016,7 +838,6 @@ class RemoteAlbumEntityCompanion
final i0.Value<String> description;
final i0.Value<DateTime> createdAt;
final i0.Value<DateTime> updatedAt;
final i0.Value<String> ownerId;
final i0.Value<String?> thumbnailAssetId;
final i0.Value<bool> isActivityEnabled;
final i0.Value<i2.AlbumAssetOrder> order;
@ -1026,7 +847,6 @@ class RemoteAlbumEntityCompanion
this.description = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
this.ownerId = const i0.Value.absent(),
this.thumbnailAssetId = const i0.Value.absent(),
this.isActivityEnabled = const i0.Value.absent(),
this.order = const i0.Value.absent(),
@ -1037,13 +857,11 @@ class RemoteAlbumEntityCompanion
this.description = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
required String ownerId,
this.thumbnailAssetId = const i0.Value.absent(),
this.isActivityEnabled = const i0.Value.absent(),
required i2.AlbumAssetOrder order,
}) : id = i0.Value(id),
name = i0.Value(name),
ownerId = i0.Value(ownerId),
order = i0.Value(order);
static i0.Insertable<i1.RemoteAlbumEntityData> custom({
i0.Expression<String>? id,
@ -1051,7 +869,6 @@ class RemoteAlbumEntityCompanion
i0.Expression<String>? description,
i0.Expression<DateTime>? createdAt,
i0.Expression<DateTime>? updatedAt,
i0.Expression<String>? ownerId,
i0.Expression<String>? thumbnailAssetId,
i0.Expression<bool>? isActivityEnabled,
i0.Expression<int>? order,
@ -1062,7 +879,6 @@ class RemoteAlbumEntityCompanion
if (description != null) 'description': description,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (ownerId != null) 'owner_id': ownerId,
if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId,
if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled,
if (order != null) 'order': order,
@ -1075,7 +891,6 @@ class RemoteAlbumEntityCompanion
i0.Value<String>? description,
i0.Value<DateTime>? createdAt,
i0.Value<DateTime>? updatedAt,
i0.Value<String>? ownerId,
i0.Value<String?>? thumbnailAssetId,
i0.Value<bool>? isActivityEnabled,
i0.Value<i2.AlbumAssetOrder>? order,
@ -1086,7 +901,6 @@ class RemoteAlbumEntityCompanion
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId,
isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled,
order: order ?? this.order,
@ -1111,9 +925,6 @@ class RemoteAlbumEntityCompanion
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
if (ownerId.present) {
map['owner_id'] = i0.Variable<String>(ownerId.value);
}
if (thumbnailAssetId.present) {
map['thumbnail_asset_id'] = i0.Variable<String>(thumbnailAssetId.value);
}
@ -1136,7 +947,6 @@ class RemoteAlbumEntityCompanion
..write('description: $description, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('thumbnailAssetId: $thumbnailAssetId, ')
..write('isActivityEnabled: $isActivityEnabled, ')
..write('order: $order')

View File

@ -84,7 +84,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 23;
int get schemaVersion => 24;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -246,6 +246,10 @@ class Drift extends $Drift {
),
);
},
from23To24: (m, v24) async {
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
},
),
);

View File

@ -105,7 +105,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
localAlbumEntity,
localAlbumAssetEntity,
i7.idxLocalAlbumAssetAlbumAsset,
i5.idxRemoteAlbumOwnerId,
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i3.idxStackPrimaryAssetId,
@ -160,15 +159,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
result: [i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',

View File

@ -11824,6 +11824,557 @@ i1.GeneratedColumn<int> _column_209(String aliasedName) =>
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
final class Schema24 extends i0.VersionedSchema {
Schema24({required super.database}) : super(version: 24);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape34 remoteAssetEntity = Shape34(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
}
class Shape48 extends i0.VersionedTable {
Shape48({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get thumbnailAssetId =>
columnsByName['thumbnail_asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isActivityEnabled =>
columnsByName['is_activity_enabled']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get order =>
columnsByName['order']! as i1.GeneratedColumn<int>;
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -11847,6 +12398,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -11960,6 +12512,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from22To23(migrator, schema);
return 23;
case 23:
final schema = Schema24(database: database);
final migrator = i1.Migrator(database, schema);
await from23To24(migrator, schema);
return 24;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -11989,6 +12546,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -12013,5 +12571,6 @@ i1.OnUpgrade stepByStep({
from20To21: from20To21,
from21To22: from21To22,
from22To23: from22To23,
from23To24: from23To24,
),
);

View File

@ -32,17 +32,23 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
useColumns: false,
),
leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumUserEntity.userId) &
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id) &
_db.remoteAlbumUserEntity.role.equalsValue(AlbumUserRole.owner),
useColumns: false,
),
]);
query
..where(_db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.userEntity.name])
..addColumns([_db.userEntity.name, _db.userEntity.id])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..groupBy([_db.remoteAlbumEntity.id]);
@ -63,6 +69,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.readTable(_db.remoteAlbumEntity)
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerId: row.read(_db.userEntity.id)!,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
),
@ -85,20 +92,22 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
useColumns: false,
),
leftOuterJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumUserEntity.userId) &
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id) &
_db.remoteAlbumUserEntity.role.equalsValue(AlbumUserRole.owner),
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.userEntity.name])
..addColumns([_db.userEntity.name, _db.userEntity.id])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..groupBy([_db.remoteAlbumEntity.id]);
@ -108,6 +117,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.readTable(_db.remoteAlbumEntity)
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerId: row.read(_db.userEntity.id)!,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
),
@ -116,12 +126,29 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
final query = _db.remoteAlbumEntity.select()
..where((row) => row.name.equals(albumName) & row.ownerId.equals(ownerId))
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
final query = _db.remoteAlbumEntity.select().join([
innerJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id) &
_db.remoteAlbumUserEntity.userId.equals(ownerId) &
_db.remoteAlbumUserEntity.role.equalsValue(AlbumUserRole.owner),
useColumns: false,
),
]);
query
..addColumns([_db.remoteAlbumUserEntity.userId])
..where(_db.remoteAlbumEntity.name.equals(albumName))
..orderBy([OrderingTerm.desc(_db.remoteAlbumEntity.createdAt)])
..limit(1);
return query.map((row) => row.toDto(ownerName: '', isShared: false)).getSingleOrNull();
return query
.map(
(row) => row
.readTable(_db.remoteAlbumEntity)
.toDto(ownerId: row.read(_db.remoteAlbumUserEntity.userId)!, ownerName: '', isShared: false),
)
.getSingleOrNull();
}
Future<void> create(RemoteAlbum album, List<String> assetIds) async {
@ -129,7 +156,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final entity = RemoteAlbumEntityCompanion(
id: Value(album.id),
name: Value(album.name),
ownerId: Value(album.ownerId),
createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt),
description: Value(album.description),
@ -140,6 +166,14 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
await _db.remoteAlbumEntity.insertOne(entity);
await _db.remoteAlbumUserEntity.insertOne(
RemoteAlbumUserEntityCompanion(
albumId: Value(album.id),
userId: Value(album.ownerId),
role: const Value(AlbumUserRole.owner),
),
);
if (assetIds.isNotEmpty) {
final albumAssets = assetIds.map(
(assetId) => RemoteAlbumAssetEntityCompanion(albumId: Value(album.id), assetId: Value(assetId)),
@ -157,7 +191,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
RemoteAlbumEntityCompanion(
id: Value(album.id),
name: Value(album.name),
ownerId: Value(album.ownerId),
createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt),
description: Value(album.description),
@ -197,7 +230,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
Future<List<UserDto>> getSharedUsers(String albumId) async {
final albumUserRows = await (_db.select(
_db.remoteAlbumUserEntity,
)..where((row) => row.albumId.equals(albumId))).get();
)..where((row) => row.albumId.equals(albumId) & row.role.isNotValue(AlbumUserRole.owner.index))).get();
if (albumUserRows.isEmpty) {
return [];
@ -295,19 +328,21 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
useColumns: false,
),
leftOuterJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumUserEntity.userId) &
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id) &
_db.remoteAlbumUserEntity.role.equalsValue(AlbumUserRole.owner),
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.equals(albumId))
..addColumns([_db.userEntity.name])
..addColumns([_db.userEntity.name, _db.userEntity.id])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..groupBy([_db.remoteAlbumEntity.id]);
@ -315,6 +350,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final album = row
.readTable(_db.remoteAlbumEntity)
.toDto(
ownerId: row.read(_db.userEntity.id)!,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
);
@ -355,6 +391,37 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
return _db.managers.remoteAlbumEntity.count();
}
Future<UserDto> getOwner(String albumId) {
final query =
_db.userEntity.select().join([
innerJoin(
_db.remoteAlbumUserEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumUserEntity.userId),
useColumns: false,
),
])..where(
_db.remoteAlbumUserEntity.albumId.equals(albumId) &
_db.remoteAlbumUserEntity.role.equalsValue(AlbumUserRole.owner),
);
return query
.map(
(row) => UserDto(
id: row.read(_db.userEntity.id)!,
email: row.read(_db.userEntity.email)!,
name: row.read(_db.userEntity.name)!,
memoryEnabled: true,
inTimeline: false,
isPartnerSharedBy: false,
isPartnerSharedWith: false,
profileChangedAt: row.read(_db.userEntity.profileChangedAt)!,
hasProfileImage: row.read(_db.userEntity.hasProfileImage)!,
avatarColor: AvatarColor.values[row.read(_db.userEntity.avatarColor)!],
),
)
.getSingle();
}
Future<List<String>> getLinkedAssetIds(String userId, String localAlbumId, String remoteAlbumId) async {
// Find remote asset ids that:
// 1. Belong to the provided local album (via local_album_asset_entity)
@ -416,21 +483,23 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
useColumns: false,
),
leftOuterJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAlbumUserEntity,
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumUserEntity.userId) &
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id) &
_db.remoteAlbumUserEntity.role.equalsValue(AlbumUserRole.owner),
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.isIn(albumIds) & _db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..addColumns([_db.userEntity.name])
..addColumns([_db.userEntity.name, _db.userEntity.id])
..groupBy([_db.remoteAlbumEntity.id]);
return query
@ -438,6 +507,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
(row) => row
.readTable(_db.remoteAlbumEntity)
.toDto(
ownerId: row.read(_db.userEntity.id)!,
ownerName: row.read(_db.userEntity.name) ?? '',
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
assetCount: row.read(assetCount) ?? 0,
@ -448,7 +518,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}
extension on RemoteAlbumEntityData {
RemoteAlbum toDto({int assetCount = 0, required String ownerName, required bool isShared}) {
RemoteAlbum toDto({int assetCount = 0, required String ownerName, required String ownerId, required bool isShared}) {
return RemoteAlbum(
id: id,
name: name,

View File

@ -53,7 +53,10 @@ class SyncApiRepository {
SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1,
SyncRequestType.albumsV1,
if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
SyncRequestType.albumsV1
else
SyncRequestType.albumsV2,
SyncRequestType.albumUsersV1,
SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1,
@ -162,6 +165,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumV1: SyncAlbumV1.fromJson,
SyncEntityType.albumV2: SyncAlbumV2.fromJson,
SyncEntityType.albumDeleteV1: SyncAlbumDeleteV1.fromJson,
SyncEntityType.albumUserV1: SyncAlbumUserV1.fromJson,
SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson,

View File

@ -30,7 +30,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility;
import 'package:openapi/api.dart' hide UserMetadataKey, AssetEditAction, AssetVisibility, AlbumUserRole;
class SyncStreamRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftSyncStreamRepository');
@ -397,6 +397,47 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
Future<void> updateAlbumsV1(Iterable<SyncAlbumV1> data) async {
try {
await _db.transaction(() async {
await _db.batch((batch) {
for (final album in data) {
final companion = RemoteAlbumEntityCompanion(
name: Value(album.name),
description: Value(album.description),
isActivityEnabled: Value(album.isActivityEnabled),
order: Value(album.order.toAlbumAssetOrder()),
thumbnailAssetId: Value(album.thumbnailAssetId),
createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt),
);
batch.insert(
_db.remoteAlbumEntity,
companion.copyWith(id: Value(album.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
await _db.batch((batch) {
for (final album in data) {
final companion = RemoteAlbumUserEntityCompanion(
albumId: Value(album.id),
userId: Value(album.ownerId),
role: const Value(AlbumUserRole.owner),
);
batch.insert(_db.remoteAlbumUserEntity, companion, onConflict: DoUpdate((_) => companion));
}
});
});
} catch (error, stack) {
_logger.severe('Error: updateAlbumsV1', error, stack);
rethrow;
}
}
Future<void> updateAlbumsV2(Iterable<SyncAlbumV2> data) async {
try {
await _db.batch((batch) {
for (final album in data) {
@ -406,7 +447,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
isActivityEnabled: Value(album.isActivityEnabled),
order: Value(album.order.toAlbumAssetOrder()),
thumbnailAssetId: Value(album.thumbnailAssetId),
ownerId: Value(album.ownerId),
createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt),
);
@ -419,7 +459,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
});
} catch (error, stack) {
_logger.severe('Error: updateAlbumsV1', error, stack);
_logger.severe('Error: updateAlbumsV2', error, stack);
rethrow;
}
}
@ -821,6 +861,7 @@ extension on api.AlbumUserRole {
AlbumUserRole toAlbumUserRole() => switch (this) {
api.AlbumUserRole.editor => AlbumUserRole.editor,
api.AlbumUserRole.viewer => AlbumUserRole.viewer,
api.AlbumUserRole.owner => AlbumUserRole.owner,
_ => throw Exception('Unknown AlbumUserRole value: $this'),
};
}

View File

@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
class RemoteAlbumState {
@ -81,7 +82,17 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
List<String> assetIds = const [],
}) async {
try {
final album = await _remoteAlbumService.createAlbum(title: title, description: description, assetIds: assetIds);
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final album = await _remoteAlbumService.createAlbum(
title: title,
owner: currentUser,
description: description,
assetIds: assetIds,
);
state = state.copyWith(albums: [...state.albums, album]);

View File

@ -1,9 +1,10 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' hide AlbumUserRole;
final driftAlbumApiRepositoryProvider = Provider(
(ref) => DriftAlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
@ -14,12 +15,17 @@ class DriftAlbumApiRepository extends ApiRepository {
DriftAlbumApiRepository(this._api);
Future<RemoteAlbum> createDriftAlbum(String name, {required Iterable<String> assetIds, String? description}) async {
Future<RemoteAlbum> createDriftAlbum(
String name,
UserDto owner, {
required Iterable<String> assetIds,
String? description,
}) async {
final responseDto = await checkNull(
_api.createAlbum(CreateAlbumDto(albumName: name, description: description, assetIds: assetIds.toList())),
);
return responseDto.toRemoteAlbum();
return responseDto.toRemoteAlbum(owner);
}
Future<({List<String> removed, List<String> failed})> removeAssets(String albumId, Iterable<String> assetIds) async {
@ -50,7 +56,8 @@ class DriftAlbumApiRepository extends ApiRepository {
}
Future<RemoteAlbum> updateAlbum(
String albumId, {
String albumId,
UserDto owner, {
String? name,
String? description,
String? thumbnailAssetId,
@ -75,17 +82,16 @@ class DriftAlbumApiRepository extends ApiRepository {
),
);
return responseDto.toRemoteAlbum();
return responseDto.toRemoteAlbum(owner);
}
Future<void> deleteAlbum(String albumId) {
return _api.deleteAlbum(albumId);
}
Future<RemoteAlbum> addUsers(String albumId, Iterable<String> userIds) async {
Future<void> addUsers(String albumId, Iterable<String> userIds) async {
final albumUsers = userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList();
final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
return response.toRemoteAlbum();
await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
}
Future<void> removeUser(String albumId, {required String userId}) async {
@ -99,11 +105,12 @@ class DriftAlbumApiRepository extends ApiRepository {
}
extension on AlbumResponseDto {
RemoteAlbum toRemoteAlbum() {
RemoteAlbum toRemoteAlbum(final UserDto user) {
return RemoteAlbum(
id: id,
name: albumName,
ownerId: owner.id,
ownerId: user.id,
ownerName: user.name,
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
@ -111,7 +118,6 @@ extension on AlbumResponseDto {
isActivityEnabled: isActivityEnabled,
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount,
ownerName: owner.name,
isShared: albumUsers.length > 2,
);
}

View File

@ -241,7 +241,8 @@ class ActionService {
}
Future<bool> setAlbumCover(String albumId, String assetId) async {
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, thumbnailAssetId: assetId);
final owner = await _remoteAlbumRepository.getOwner(albumId);
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, owner, thumbnailAssetId: assetId);
await _remoteAlbumRepository.update(updatedAlbum);
return true;
}

View File

@ -568,6 +568,7 @@ Class | Method | HTTP request | Description
- [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md)
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- [SyncAlbumV1](doc//SyncAlbumV1.md)
- [SyncAlbumV2](doc//SyncAlbumV2.md)
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
- [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md)
- [SyncAssetEditV1](doc//SyncAssetEditV1.md)

View File

@ -316,6 +316,7 @@ part 'model/sync_album_to_asset_v1.dart';
part 'model/sync_album_user_delete_v1.dart';
part 'model/sync_album_user_v1.dart';
part 'model/sync_album_v1.dart';
part 'model/sync_album_v2.dart';
part 'model/sync_asset_delete_v1.dart';
part 'model/sync_asset_edit_delete_v1.dart';
part 'model/sync_asset_edit_v1.dart';

View File

@ -678,6 +678,8 @@ class ApiClient {
return SyncAlbumUserV1.fromJson(value);
case 'SyncAlbumV1':
return SyncAlbumV1.fromJson(value);
case 'SyncAlbumV2':
return SyncAlbumV2.fromJson(value);
case 'SyncAssetDeleteV1':
return SyncAssetDeleteV1.fromJson(value);
case 'SyncAssetEditDeleteV1':

View File

@ -26,8 +26,6 @@ class AlbumResponseDto {
required this.isActivityEnabled,
this.lastModifiedAssetTimestamp,
this.order,
required this.owner,
required this.ownerId,
required this.shared,
this.startDate,
required this.updatedAt,
@ -39,6 +37,7 @@ class AlbumResponseDto {
/// Thumbnail asset ID
String? albumThumbnailAssetId;
/// First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.
List<AlbumUserResponseDto> albumUsers;
/// Number of assets
@ -90,11 +89,6 @@ class AlbumResponseDto {
///
AssetOrder? order;
UserResponseDto owner;
/// Owner user ID
String ownerId;
/// Is shared album
bool shared;
@ -125,8 +119,6 @@ class AlbumResponseDto {
other.isActivityEnabled == isActivityEnabled &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.order == order &&
other.owner == owner &&
other.ownerId == ownerId &&
other.shared == shared &&
other.startDate == startDate &&
other.updatedAt == updatedAt;
@ -147,14 +139,12 @@ class AlbumResponseDto {
(isActivityEnabled.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(order == null ? 0 : order!.hashCode) +
(owner.hashCode) +
(ownerId.hashCode) +
(shared.hashCode) +
(startDate == null ? 0 : startDate!.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -187,8 +177,6 @@ class AlbumResponseDto {
} else {
// json[r'order'] = null;
}
json[r'owner'] = this.owner;
json[r'ownerId'] = this.ownerId;
json[r'shared'] = this.shared;
if (this.startDate != null) {
json[r'startDate'] = this.startDate!.toUtc().toIso8601String();
@ -221,8 +209,6 @@ class AlbumResponseDto {
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
order: AssetOrder.fromJson(json[r'order']),
owner: UserResponseDto.fromJson(json[r'owner'])!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
shared: mapValueOfType<bool>(json, r'shared')!,
startDate: mapDateTime(json, r'startDate', r''),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
@ -282,8 +268,6 @@ class AlbumResponseDto {
'hasSharedLink',
'id',
'isActivityEnabled',
'owner',
'ownerId',
'shared',
'updatedAt',
};

View File

@ -24,11 +24,13 @@ class AlbumUserRole {
String toJson() => value;
static const editor = AlbumUserRole._(r'editor');
static const owner = AlbumUserRole._(r'owner');
static const viewer = AlbumUserRole._(r'viewer');
/// List of all possible values in this [enum][AlbumUserRole].
static const values = <AlbumUserRole>[
editor,
owner,
viewer,
];
@ -69,6 +71,7 @@ class AlbumUserRoleTypeTransformer {
if (data != null) {
switch (data) {
case r'editor': return AlbumUserRole.editor;
case r'owner': return AlbumUserRole.owner;
case r'viewer': return AlbumUserRole.viewer;
default:
if (!allowNull) {

View File

@ -0,0 +1,170 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAlbumV2 {
/// Returns a new [SyncAlbumV2] instance.
SyncAlbumV2({
required this.createdAt,
required this.description,
required this.id,
required this.isActivityEnabled,
required this.name,
required this.order,
required this.thumbnailAssetId,
required this.updatedAt,
});
/// Created at
DateTime createdAt;
/// Album description
String description;
/// Album ID
String id;
/// Is activity enabled
bool isActivityEnabled;
/// Album name
String name;
AssetOrder order;
/// Thumbnail asset ID
String? thumbnailAssetId;
/// Updated at
DateTime updatedAt;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAlbumV2 &&
other.createdAt == createdAt &&
other.description == description &&
other.id == id &&
other.isActivityEnabled == isActivityEnabled &&
other.name == name &&
other.order == order &&
other.thumbnailAssetId == thumbnailAssetId &&
other.updatedAt == updatedAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(createdAt.hashCode) +
(description.hashCode) +
(id.hashCode) +
(isActivityEnabled.hashCode) +
(name.hashCode) +
(order.hashCode) +
(thumbnailAssetId == null ? 0 : thumbnailAssetId!.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'SyncAlbumV2[createdAt=$createdAt, description=$description, id=$id, isActivityEnabled=$isActivityEnabled, name=$name, order=$order, thumbnailAssetId=$thumbnailAssetId, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.createdAt.millisecondsSinceEpoch
: this.createdAt.toUtc().toIso8601String();
json[r'description'] = this.description;
json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled;
json[r'name'] = this.name;
json[r'order'] = this.order;
if (this.thumbnailAssetId != null) {
json[r'thumbnailAssetId'] = this.thumbnailAssetId;
} else {
// json[r'thumbnailAssetId'] = null;
}
json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.updatedAt.millisecondsSinceEpoch
: this.updatedAt.toUtc().toIso8601String();
return json;
}
/// Returns a new [SyncAlbumV2] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAlbumV2? fromJson(dynamic value) {
upgradeDto(value, "SyncAlbumV2");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAlbumV2(
createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
description: mapValueOfType<String>(json, r'description')!,
id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
name: mapValueOfType<String>(json, r'name')!,
order: AssetOrder.fromJson(json[r'order'])!,
thumbnailAssetId: mapValueOfType<String>(json, r'thumbnailAssetId'),
updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
);
}
return null;
}
static List<SyncAlbumV2> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAlbumV2>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAlbumV2.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAlbumV2> mapFromJson(dynamic json) {
final map = <String, SyncAlbumV2>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAlbumV2.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAlbumV2-objects as value to a dart map
static Map<String, List<SyncAlbumV2>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAlbumV2>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAlbumV2.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'createdAt',
'description',
'id',
'isActivityEnabled',
'name',
'order',
'thumbnailAssetId',
'updatedAt',
};
}

View File

@ -44,6 +44,7 @@ class SyncEntityType {
static const partnerStackDeleteV1 = SyncEntityType._(r'PartnerStackDeleteV1');
static const partnerStackV1 = SyncEntityType._(r'PartnerStackV1');
static const albumV1 = SyncEntityType._(r'AlbumV1');
static const albumV2 = SyncEntityType._(r'AlbumV2');
static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1');
static const albumUserV1 = SyncEntityType._(r'AlbumUserV1');
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
@ -97,6 +98,7 @@ class SyncEntityType {
partnerStackDeleteV1,
partnerStackV1,
albumV1,
albumV2,
albumDeleteV1,
albumUserV1,
albumUserBackfillV1,
@ -185,6 +187,7 @@ class SyncEntityTypeTypeTransformer {
case r'PartnerStackDeleteV1': return SyncEntityType.partnerStackDeleteV1;
case r'PartnerStackV1': return SyncEntityType.partnerStackV1;
case r'AlbumV1': return SyncEntityType.albumV1;
case r'AlbumV2': return SyncEntityType.albumV2;
case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1;
case r'AlbumUserV1': return SyncEntityType.albumUserV1;
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;

View File

@ -24,6 +24,7 @@ class SyncRequestType {
String toJson() => value;
static const albumsV1 = SyncRequestType._(r'AlbumsV1');
static const albumsV2 = SyncRequestType._(r'AlbumsV2');
static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1');
static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1');
static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1');
@ -49,6 +50,7 @@ class SyncRequestType {
/// List of all possible values in this [enum][SyncRequestType].
static const values = <SyncRequestType>[
albumsV1,
albumsV2,
albumUsersV1,
albumToAssetsV1,
albumAssetsV1,
@ -109,6 +111,7 @@ class SyncRequestTypeTypeTransformer {
if (data != null) {
switch (data) {
case r'AlbumsV1': return SyncRequestType.albumsV1;
case r'AlbumsV2': return SyncRequestType.albumsV2;
case r'AlbumUsersV1': return SyncRequestType.albumUsersV1;
case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1;
case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1;

View File

@ -27,6 +27,7 @@ import 'schema_v20.dart' as v20;
import 'schema_v21.dart' as v21;
import 'schema_v22.dart' as v22;
import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -78,6 +79,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v22.DatabaseAtV22(db);
case 23:
return v23.DatabaseAtV23(db);
case 24:
return v24.DatabaseAtV24(db);
default:
throw MissingSchemaException(version, versions);
}
@ -107,5 +110,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
21,
22,
23,
24,
];
}

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
@ -151,13 +152,13 @@ class MediumRepositoryContext {
String? thumbnailAssetId,
}) async {
id = TestUtils.uuid(id);
return db
final album = await db
.into(db.remoteAlbumEntity)
.insertReturning(
RemoteAlbumEntityCompanion(
id: Value(id),
name: Value(name ?? 'remote_album_$id'),
ownerId: Value(TestUtils.uuid(ownerId)),
createdAt: Value(TestUtils.date(createdAt)),
updatedAt: Value(TestUtils.date(updatedAt)),
description: Value(description ?? 'Description for album $id'),
@ -166,6 +167,18 @@ class MediumRepositoryContext {
thumbnailAssetId: Value(thumbnailAssetId),
),
);
await db
.into(db.remoteAlbumUserEntity)
.insert(
RemoteAlbumUserEntityCompanion.insert(
albumId: id,
userId: ownerId ?? const Uuid().v4(),
role: AlbumUserRole.owner,
),
);
return album;
}
Future<void> insertRemoteAlbumAsset({required String albumId, required String assetId}) {

View File

@ -15279,9 +15279,11 @@
"type": "string"
},
"albumUsers": {
"description": "First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.",
"items": {
"$ref": "#/components/schemas/AlbumUserResponseDto"
},
"minItems": 1,
"type": "array"
},
"assetCount": {
@ -15330,13 +15332,6 @@
"order": {
"$ref": "#/components/schemas/AssetOrder"
},
"owner": {
"$ref": "#/components/schemas/UserResponseDto"
},
"ownerId": {
"description": "Owner user ID",
"type": "string"
},
"shared": {
"description": "Is shared album",
"type": "boolean"
@ -15362,8 +15357,6 @@
"hasSharedLink",
"id",
"isActivityEnabled",
"owner",
"ownerId",
"shared",
"updatedAt"
],
@ -15453,6 +15446,7 @@
"description": "Album user role",
"enum": [
"editor",
"owner",
"viewer"
],
"type": "string"
@ -22531,6 +22525,59 @@
],
"type": "object"
},
"SyncAlbumV2": {
"properties": {
"createdAt": {
"description": "Created at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"description": {
"description": "Album description",
"type": "string"
},
"id": {
"description": "Album ID",
"type": "string"
},
"isActivityEnabled": {
"description": "Is activity enabled",
"type": "boolean"
},
"name": {
"description": "Album name",
"type": "string"
},
"order": {
"$ref": "#/components/schemas/AssetOrder"
},
"thumbnailAssetId": {
"description": "Thumbnail asset ID",
"nullable": true,
"type": "string"
},
"updatedAt": {
"description": "Updated at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
}
},
"required": [
"createdAt",
"description",
"id",
"isActivityEnabled",
"name",
"order",
"thumbnailAssetId",
"updatedAt"
],
"type": "object"
},
"SyncAssetDeleteV1": {
"properties": {
"assetId": {
@ -23216,6 +23263,7 @@
"PartnerStackDeleteV1",
"PartnerStackV1",
"AlbumV1",
"AlbumV2",
"AlbumDeleteV1",
"AlbumUserV1",
"AlbumUserBackfillV1",
@ -23510,6 +23558,7 @@
"description": "Sync request type",
"enum": [
"AlbumsV1",
"AlbumsV2",
"AlbumUsersV1",
"AlbumToAssetsV1",
"AlbumAssetsV1",

View File

@ -457,6 +457,7 @@ export type AlbumResponseDto = {
albumName: string;
/** Thumbnail asset ID */
albumThumbnailAssetId: string | null;
/** First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically. */
albumUsers: AlbumUserResponseDto[];
/** Number of assets */
assetCount: number;
@ -476,9 +477,6 @@ export type AlbumResponseDto = {
/** Last modified asset timestamp */
lastModifiedAssetTimestamp?: string;
order?: AssetOrder;
owner: UserResponseDto;
/** Owner user ID */
ownerId: string;
/** Is shared album */
shared: boolean;
/** Start date (earliest asset) */
@ -2881,6 +2879,23 @@ export type SyncAlbumV1 = {
/** Updated at */
updatedAt: string;
};
export type SyncAlbumV2 = {
/** Created at */
createdAt: string;
/** Album description */
description: string;
/** Album ID */
id: string;
/** Is activity enabled */
isActivityEnabled: boolean;
/** Album name */
name: string;
order: AssetOrder;
/** Thumbnail asset ID */
thumbnailAssetId: string | null;
/** Updated at */
updatedAt: string;
};
export type SyncAssetDeleteV1 = {
/** Asset ID */
assetId: string;
@ -6731,6 +6746,7 @@ export enum AssetVisibility {
}
export enum AlbumUserRole {
Editor = "editor",
Owner = "owner",
Viewer = "viewer"
}
export enum BulkIdErrorReason {
@ -7110,6 +7126,7 @@ export enum SyncEntityType {
PartnerStackDeleteV1 = "PartnerStackDeleteV1",
PartnerStackV1 = "PartnerStackV1",
AlbumV1 = "AlbumV1",
AlbumV2 = "AlbumV2",
AlbumDeleteV1 = "AlbumDeleteV1",
AlbumUserV1 = "AlbumUserV1",
AlbumUserBackfillV1 = "AlbumUserBackfillV1",
@ -7142,6 +7159,7 @@ export enum SyncEntityType {
}
export enum SyncRequestType {
AlbumsV1 = "AlbumsV1",
AlbumsV2 = "AlbumsV2",
AlbumUsersV1 = "AlbumUsersV1",
AlbumToAssetsV1 = "AlbumToAssetsV1",
AlbumAssetsV1 = "AlbumAssetsV1",

View File

@ -195,7 +195,6 @@ export type SharedLink = {
};
export type Album = Selectable<AlbumTable> & {
owner: ShallowDehydrateObject<User>;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
};

View File

@ -1,7 +1,6 @@
import { ShallowDehydrateObject } from 'kysely';
import _ from 'lodash';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
@ -104,7 +103,6 @@ const ContributorCountResponseSchema = z
export const AlbumResponseSchema = z
.object({
id: z.string().describe('Album ID'),
ownerId: z.string().describe('Owner user ID'),
albumName: z.string().describe('Album name'),
description: z.string().describe('Album description'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@ -113,9 +111,13 @@ export const AlbumResponseSchema = z
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
albumThumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
shared: z.boolean().describe('Is shared album'),
albumUsers: z.array(AlbumUserResponseSchema),
albumUsers: z
.array(AlbumUserResponseSchema)
.min(1)
.describe(
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
),
hasSharedLink: z.boolean().describe('Has shared link'),
owner: UserResponseSchema,
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
@ -155,8 +157,6 @@ export type MapAlbumDto = {
createdAt: Date;
updatedAt: Date;
id: string;
ownerId: string;
owner: ShallowDehydrateObject<User>;
isActivityEnabled: boolean;
order: AssetOrder;
};
@ -174,12 +174,10 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
}
}
const albumUsersSorted = _.orderBy(albumUsers, ['role', 'user.name']);
const assets = entity.assets || [];
const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
const hasSharedUser = albumUsers.length > 0;
const hasSharedUser = albumUsers.length > 1;
let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime;
@ -195,9 +193,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
id: entity.id,
ownerId: entity.ownerId,
owner: mapUser(entity.owner),
albumUsers: albumUsersSorted,
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
startDate: asDateString(startDate),

View File

@ -2,6 +2,7 @@
import { createZodDto } from 'nestjs-zod';
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
AlbumUserRoleSchema,
AssetOrderSchema,
AssetTypeSchema,
@ -211,6 +212,19 @@ const SyncAlbumV1Schema = z
})
.meta({ id: 'SyncAlbumV1' });
const SyncAlbumV2Schema = z
.object({
id: z.string().describe('Album ID'),
name: z.string().describe('Album name'),
description: z.string().describe('Album description'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
thumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
isActivityEnabled: z.boolean().describe('Is activity enabled'),
order: AssetOrderSchema,
})
.meta({ id: 'SyncAlbumV2' });
const SyncAlbumToAssetV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
@ -234,10 +248,21 @@ class SyncAlbumUserV1 extends createZodDto(SyncAlbumUserV1Schema) {}
@ExtraModel()
class SyncAlbumV1 extends createZodDto(SyncAlbumV1Schema) {}
@ExtraModel()
class SyncAlbumV2 extends createZodDto(SyncAlbumV2Schema) {}
@ExtraModel()
class SyncAlbumToAssetV1 extends createZodDto(SyncAlbumToAssetV1Schema) {}
@ExtraModel()
class SyncAlbumToAssetDeleteV1 extends createZodDto(SyncAlbumToAssetDeleteV1Schema) {}
export function syncAlbumV2ToV1(
albumV2: SyncAlbumV2,
albumUsers: { userId: string; role: AlbumUserRole }[],
): SyncAlbumV1 {
const owner = albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
return { ...albumV2, ownerId: owner.userId };
}
const SyncMemoryV1Schema = z
.object({
id: z.string().describe('Memory ID'),
@ -407,6 +432,7 @@ export type SyncItem = {
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1;
[SyncEntityType.AlbumV1]: SyncAlbumV1;
[SyncEntityType.AlbumV2]: SyncAlbumV2;
[SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;

View File

@ -61,6 +61,7 @@ export enum AssetFileType {
export enum AlbumUserRole {
Editor = 'editor',
Owner = 'owner',
Viewer = 'viewer',
}
@ -797,6 +798,7 @@ export enum ExitCode {
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumsV2 = 'AlbumsV2',
AlbumUsersV1 = 'AlbumUsersV1',
AlbumToAssetsV1 = 'AlbumToAssetsV1',
AlbumAssetsV1 = 'AlbumAssetsV1',
@ -852,6 +854,7 @@ export enum SyncEntityType {
PartnerStackV1 = 'PartnerStackV1',
AlbumV1 = 'AlbumV1',
AlbumV2 = 'AlbumV2',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',

View File

@ -14,27 +14,26 @@ select
"activity"."id"
from
"activity"
left join "album" on "activity"."albumId" = "album"."id"
inner join "album" on "activity"."albumId" = "album"."id"
and "album"."deletedAt" is null
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1::uuid
where
"activity"."id" in ($1)
and "album"."ownerId" = $2::uuid
"activity"."id" in ($2)
-- AccessRepository.activity.checkCreateAccess
select
"album"."id"
from
"album"
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."userId"
inner join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
inner join "user" on "user"."id" = "albumUsers"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
and "album"."isActivityEnabled" = $2
and (
"album"."ownerId" = $3
or "user"."id" = $4
)
and "user"."id" = $3
and "album"."deletedAt" is null
-- AccessRepository.album.checkOwnerAccess
@ -42,9 +41,11 @@ select
"album"."id"
from
"album"
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1
where
"album"."id" in ($1)
and "album"."ownerId" = $2
"album"."id" in ($2)
and "album"."deletedAt" is null
-- AccessRepository.album.checkSharedAlbumAccess
@ -52,8 +53,8 @@ select
"album"."id"
from
"album"
left join "album_user" on "album_user"."albumId" = "album"."id"
left join "user" on "user"."id" = "album_user"."userId"
inner join "album_user" on "album_user"."albumId" = "album"."id"
inner join "user" on "user"."id" = "album_user"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
@ -93,10 +94,7 @@ where
"asset"."id" = any (target.ids)
or "asset"."livePhotoVideoId" = any (target.ids)
)
and (
"album"."ownerId" = $2
or "user"."id" = $3
)
and "user"."id" = $2
and "album"."deletedAt" is null
-- AccessRepository.asset.checkOwnerAccess

View File

@ -1,26 +1,17 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AlbumRepository.getById
with
"album_user" as (
select
*
from
"album_user"
where
"album_user"."albumId" = $1
)
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@ -41,15 +32,21 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $2 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
@ -88,30 +85,12 @@ select
from
"album"
where
"album"."id" = $1
"album"."id" = $3
and "album"."deletedAt" is null
-- AlbumRepository.getByAssetId
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@ -132,36 +111,38 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
and "album_asset"."assetId" = $3
and "album"."deletedAt" is null
order by
"album"."createdAt" desc,
"album"."createdAt" desc
-- AlbumRepository.getByAssetIds
@ -172,18 +153,15 @@ from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $1
)
and "album_asset"."assetId" in ($3)
and "album_asset"."assetId" in ($2)
and "album"."deletedAt" is null
-- AlbumRepository.getMetadataForIds
@ -210,24 +188,6 @@ group by
-- AlbumRepository.getOwned
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@ -248,15 +208,21 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
@ -274,9 +240,11 @@ select
) as "sharedLinks"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
"album"."deletedAt" is null
order by
"album"."createdAt" desc
@ -303,35 +271,23 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@ -347,29 +303,34 @@ select
) as "sharedLinks"
from
"album"
inner join (
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $2
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."role" != 'owner'
)
union
select
"shared_link"."albumId" as "id"
from
"shared_link"
where
"shared_link"."userId" = $3
and "shared_link"."albumId" is not null
) as "matching" on "matching"."id" = "album"."id"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."role" = 'owner'
where
(
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and (
"album"."ownerId" = $1
or "album_user"."userId" = $2
)
)
or exists (
select
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
and "shared_link"."userId" = $3
)
)
and "album"."deletedAt" is null
"album"."deletedAt" is null
order by
"album"."createdAt" desc
@ -378,33 +339,68 @@ select
"album".*,
(
select
to_json(obj)
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"shared_link".*
from
"user"
"shared_link"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner"
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
"album"."deletedAt" is null
and not exists (
select
from
"album_user"
"album_user" as "au"
where
"album_user"."albumId" = "album"."id"
"au"."albumId" = "album"."id"
and "au"."role" != 'owner'
)
and not exists (
select
@ -430,6 +426,117 @@ where
"album_asset"."albumId" = $1
and "album_asset"."assetId" in ($2)
-- AlbumRepository.addAssetIds
insert into
"album_asset"
select
$1::uuid as "albumId",
unnest($2::uuid[]) as "assetId"
from
(
select
1
) as "dummy"
on conflict do nothing
-- AlbumRepository.create
with
"album" as (
insert into
"album" ("albumName")
values
($1)
returning
*
),
"album_user" as (
insert into
"album_user"
select
"album"."id" as "albumId",
unnest($2::uuid[]) as "userId",
unnest($3::album_user_role_enum[]) as "role"
from
"album"
returning
"album_user"."albumId",
"album_user"."userId",
"album_user"."role"
),
"album_asset" as (
insert into
"album_asset"
select
"album"."id" as "albumId",
unnest($4::uuid[]) as "assetId"
from
"album"
on conflict do nothing
returning
"album_asset"."albumId",
"album_asset"."assetId"
)
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"user"."name" asc
) as agg
) as "albumUsers",
(
select
json_agg("asset") as "assets"
from
(
select
"asset".*,
"asset_exif" as "exifInfo"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"album_asset"."albumId" = "album"."id"
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline')
order by
"asset"."fileCreatedAt" desc
) as "asset"
) as "assets"
from
"album"
-- AlbumRepository.getContributorCounts
select
"asset"."ownerId" as "userId",

View File

@ -139,7 +139,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where
@ -201,7 +209,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where

View File

@ -29,7 +29,6 @@ order by
-- SyncRepository.album.getUpserts
select distinct
on ("album"."id", "album"."updateId") "album"."id",
"album"."ownerId",
"album"."albumName" as "name",
"album"."description",
"album"."createdAt",
@ -44,13 +43,19 @@ from
where
"album"."updateId" < $1
and "album"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_users"."userId" = $4
)
and "album_users"."userId" = $3
order by
"album"."updateId" asc
-- SyncRepository.album.getAlbumUsers
select
"userId",
"role"
from
"album_user"
where
"albumId" = $1
-- SyncRepository.albumAsset.getBackfill
select
"asset"."id",
@ -109,16 +114,12 @@ select
from
"asset" as "asset"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset"."updateId" asc
@ -147,15 +148,11 @@ select
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@ -229,16 +226,12 @@ select
from
"asset_exif" as "asset_exif"
inner join "album_asset" on "album_asset"."assetId" = "asset_exif"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset_exif"."updateId" asc
@ -278,10 +271,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@ -312,20 +302,11 @@ where
and "album_asset_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_asset_audit"."id" asc
@ -337,15 +318,11 @@ select
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@ -377,20 +354,11 @@ where
and "album_user_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_user_audit"."id" asc
@ -408,20 +376,11 @@ where
and "album_user"."updateId" > $2
and "album_user"."albumId" in (
select
"id"
"albumUsers"."albumId" as "id"
from
"album"
"album_user" as "albumUsers"
where
"ownerId" = $3
union
(
select
"albumUsers"."albumId" as "id"
from
"album_user" as "albumUsers"
where
"albumUsers"."userId" = $4
)
"albumUsers"."userId" = $3
)
order by
"album_user"."updateId" asc

View File

@ -35,9 +35,14 @@ class ActivityAccess {
return this.db
.selectFrom('activity')
.select('activity.id')
.leftJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', asUuid(userId)),
)
.where('activity.id', 'in', [...activityIds])
.whereRef('album.ownerId', '=', asUuid(userId))
.execute()
.then((activities) => new Set(activities.map((activity) => activity.id)));
}
@ -52,11 +57,11 @@ class ActivityAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.isActivityEnabled', '=', true)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where((eb) => eb('user.id', '=', userId))
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@ -77,7 +82,12 @@ class AlbumAccess {
.selectFrom('album')
.select('album.id')
.where('album.id', 'in', [...albumIds])
.where('album.ownerId', '=', userId)
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', userId),
)
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@ -96,8 +106,8 @@ class AlbumAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user', 'album_user.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user', 'album_user.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.deletedAt', 'is', null)
.where('user.id', '=', userId)
@ -152,7 +162,7 @@ class AssetAccess {
eb('asset.livePhotoVideoId', '=', sql<string>`any(target.ids)`),
]),
)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where('user.id', '=', userId)
.where('album.deletedAt', 'is', null)
.execute()
.then((assets) => {

View File

@ -7,7 +7,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { asUuid } from 'src/utils/database';
import { asUuid, dummy } from 'src/utils/database';
export interface ActivitySearch {
albumId?: string;
@ -31,11 +31,7 @@ export class ActivityRepository {
join.onRef('user2.id', '=', 'activity.userId').on('user2.deletedAt', 'is', null),
)
.innerJoinLateral(
(eb) =>
eb
.selectFrom(sql`(select 1)`.as('dummy'))
.select(columns.userWithPrefix)
.as('user'),
(eb) => eb.selectFrom(dummy).select(columns.userWithPrefix).as('user'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))

View File

@ -14,10 +14,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { withDefaultVisibility } from 'src/utils/database';
import { asUuid, dummy, withDefaultVisibility } from 'src/utils/database';
export interface AlbumAssetCount {
albumId: string;
@ -31,33 +32,25 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
const withOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
.$notNull()
.as('owner');
};
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId'))
.$notNull()
.as('user'),
)
.whereRef('album_user.albumId', '=', 'album.id'),
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
.orderBy('user.name', 'asc'),
)
.$notNull()
.as('albumUsers');
};
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
).as('sharedLinks');
};
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
@ -80,19 +73,28 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
.as('assets');
};
const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.role', '=', AlbumUserRole.Owner)
.where('album_user.userId', '=', ownerId),
);
@Injectable()
export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
async getById(id: string, options: AlbumInfoOptions) {
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
getById(id: string, options: AlbumInfoOptions, authUserId?: string) {
return this.db
.with('album_user', (qb) => qb.selectFrom('album_user').selectAll().where('album_user.albumId', '=', id))
.selectFrom('album')
.selectAll('album')
.where('album.id', '=', id)
.where('album.deletedAt', 'is', null)
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(authUserId))
.select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets))
.$narrowType<{ assets: NotNull }>()
@ -100,27 +102,22 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string) {
getByAssetId(ownerId: string, assetId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', '=', assetId)
.where('album.deletedAt', 'is', null)
.orderBy('album.createdAt', 'desc')
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
@ -137,15 +134,12 @@ export class AlbumRepository {
.select('album.id')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', 'in', assetIds)
.where('album.deletedAt', 'is', null)
@ -190,15 +184,19 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getOwned(ownerId: string) {
getOwned(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.select(withOwner)
.select(withAlbumUsers)
.select(withSharedLink)
.where('album.ownerId', '=', ownerId)
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
@ -207,29 +205,40 @@ export class AlbumRepository {
* Get albums shared with and shared by owner.
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getShared(ownerId: string) {
getShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where((eb) =>
eb.or([
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
),
eb.exists(
eb
.selectFrom('shared_link')
.whereRef('shared_link.albumId', '=', 'album.id')
.where('shared_link.userId', '=', ownerId),
),
]),
.innerJoin(
(eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId as id')
.where('album_user.userId', '=', ownerId)
.where(
'album_user.albumId',
'in',
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)),
)
.union(
eb
.selectFrom('shared_link')
.where('shared_link.userId', '=', ownerId)
.where('shared_link.albumId', 'is not', null)
.select('shared_link.albumId as id')
.$narrowType<{ id: NotNull }>(),
)
.as('matching'),
(join) => join.onRef('matching.id', '=', 'album.id'),
)
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers)
.select(withOwner)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
@ -239,29 +248,45 @@ export class AlbumRepository {
* Get albums of owner that are _not_ shared
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getNotShared(ownerId: string) {
getNotShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where('album.ownerId', '=', ownerId)
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
.select(withOwner)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
),
),
)
.where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
)
.select(withSharedLink)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
async restoreAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: null }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: null }).where(isAlbumOwned(userId)).execute();
}
async softDeleteAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: new Date() }).where(isAlbumOwned(userId)).execute();
}
async deleteAll(userId: string): Promise<void> {
await this.db.deleteFrom('album').where('ownerId', '=', userId).execute();
await this.db.deleteFrom('album').where(isAlbumOwned(userId)).execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ -306,52 +331,86 @@ export class AlbumRepository {
.then((results) => new Set(results.map(({ assetId }) => assetId)));
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.addAssets(this.db, albumId, assetIds);
if (assetIds.length === 0) {
return;
}
await this.db
.insertInto('album_asset')
.expression((eb) =>
eb.selectFrom(dummy).select([asUuid(albumId).as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
return this.db.transaction().execute(async (tx) => {
const newAlbum = await tx.insertInto('album').values(album).returning('album.id').executeTakeFirst();
@GenerateSql({
params: [
{ albumName: DummyValue.STRING },
[],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID],
],
})
async create(
album: Insertable<AlbumTable>,
assetIds: string[],
albumUsers: AlbumUserCreateDto[],
authUserId: string,
) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
throw new Error('Album must have an owner');
}
if (!newAlbum) {
throw new Error('Failed to create album');
}
const userIds = albumUsers.map((u) => u.userId);
const roles = albumUsers.map((u) => u.role);
if (assetIds.length > 0) {
await this.addAssets(tx, newAlbum.id, assetIds);
}
if (albumUsers.length > 0) {
await tx
const result = await this.db
.with('album', (db) => db.insertInto('album').values(album).returningAll())
.with('album_user', (db) =>
db
.insertInto('album_user')
.values(
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [
ref('album.id').as('albumId'),
sql`unnest(${userIds}::uuid[])`.as('userId'),
sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
]),
)
.execute();
}
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
)
.with('album_asset', (db) =>
db
.insertInto('album_asset')
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [ref('album.id').as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.returning(['album_asset.albumId', 'album_asset.assetId']),
)
.selectFrom('album')
.selectAll('album')
.select(withAlbumUsers(authUserId))
.select(withAssets)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
return tx
.selectFrom('album')
.selectAll('album')
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withAssets)
.select(withAlbumUsers)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
});
return result;
}
update(id: string, album: Updateable<AlbumTable>) {
update(id: string, album: Updateable<AlbumTable>, authUserId: string) {
return this.db
.updateTable('album')
.set(album)
.where('id', '=', id)
.where('album.id', '=', id)
.returningAll('album')
.returning(withOwner)
.returning(withSharedLink)
.returning(withAlbumUsers)
.returning(withAlbumUsers(authUserId))
.executeTakeFirstOrThrow();
}
@ -359,19 +418,6 @@ export class AlbumRepository {
await this.db.deleteFrom('album').where('id', '=', id).execute();
}
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute();
}
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
if (values.length === 0) {
@ -402,7 +448,7 @@ export class AlbumRepository {
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
.select('album_asset.assetId')
.orderBy('asset.fileCreatedAt', 'desc')
.limit(1),
.limit(sql.lit(1)),
}))
.where((eb) =>
eb.or([

View File

@ -39,7 +39,7 @@ type EventMap = {
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumInvite: [{ id: string; userId: string }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events
AssetCreate: [{ asset: Asset }];

View File

@ -9,7 +9,7 @@ import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions {
@ -418,7 +418,7 @@ export class PersonRepository {
(query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
}
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
await query.selectFrom(dummy).execute();
}
async update(person: Updateable<PersonTable> & { id: string }) {

View File

@ -5,7 +5,7 @@ import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkType } from 'src/enum';
import { AlbumUserRole, SharedLinkType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@ -39,7 +39,15 @@ const withAlbumOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
.selectFrom('user')
.select(columns.user)
.whereRef('user.id', '=', 'album.ownerId')
.where((eb) =>
eb.exists(
eb
.selectFrom('album_user')
.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.whereRef('album_user.albumId', '=', 'album.id')
.whereRef('album_user.userId', '=', 'user.id'),
),
)
.where('user.deletedAt', 'is', null)
.as('owner');
};

View File

@ -171,10 +171,9 @@ class AlbumSync extends BaseSync {
return this.upsertQuery('album', options)
.distinctOn(['album.id', 'album.updateId'])
.leftJoin('album_user as album_users', 'album.id', 'album_users.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.userId', '=', userId)]))
.where('album_users.userId', '=', userId)
.select([
'album.id',
'album.ownerId',
'album.albumName as name',
'album.description',
'album.createdAt',
@ -186,6 +185,11 @@ class AlbumSync extends BaseSync {
])
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getAlbumUsers(albumId: string) {
return this.db.selectFrom('album_user').select(['userId', 'role']).where('albumId', '=', albumId).execute();
}
}
class AlbumAssetSync extends BaseSync {
@ -207,9 +211,8 @@ class AlbumAssetSync extends BaseSync {
.select(columns.syncAsset)
.select('asset.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
@ -220,9 +223,8 @@ class AlbumAssetSync extends BaseSync {
.select('album_asset.updateId')
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
}
@ -246,9 +248,8 @@ class AlbumAssetExifSync extends BaseSync {
.select(columns.syncAssetExif)
.select('asset_exif.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send exif updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
@ -261,7 +262,7 @@ class AlbumAssetExifSync extends BaseSync {
.select(columns.syncAssetExif)
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.where('album_user.userId', '=', userId)
.stream();
}
}
@ -284,18 +285,7 @@ class AlbumToAssetSync extends BaseSync {
eb(
'albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
),
)
.stream();
@ -310,9 +300,8 @@ class AlbumToAssetSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('album_asset', options)
.select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId'])
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
}
@ -336,18 +325,7 @@ class AlbumUserSync extends BaseSync {
eb(
'albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
),
)
.stream();
@ -368,17 +346,9 @@ class AlbumUserSync extends BaseSync {
'album_user.albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user as albumUsers')
.select(['albumUsers.albumId as id'])
.where('albumUsers.userId', '=', userId),
),
),
.selectFrom('album_user as albumUsers')
.select(['albumUsers.albumId as id'])
.where('albumUsers.userId', '=', userId),
),
)
.stream();

View File

@ -1,5 +1,10 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
values: [AlbumUserRole.Owner, AlbumUserRole.Editor, AlbumUserRole.Viewer],
});
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',

View File

@ -29,7 +29,8 @@ export const album_user_after_insert = registerFunction({
body: `
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END`,
});
@ -119,19 +120,6 @@ export const asset_delete_audit = registerFunction({
END`,
});
export const album_delete_audit = registerFunction({
name: 'album_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});
export const album_asset_delete_audit = registerFunction({
name: 'album_asset_delete_audit',
returnType: 'TRIGGER',

View File

@ -1,7 +1,11 @@
import { Database, Extensions, Generated, Int8 } from '@immich/sql-tools';
import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import {
album_delete_audit,
album_user_role_enum,
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
} from 'src/schema/enums';
import {
album_user_after_insert,
album_user_delete_audit,
asset_delete_audit,
@ -146,7 +150,6 @@ export class ImmichDatabase {
user_delete_audit,
partner_delete_audit,
asset_delete_audit,
album_delete_audit,
album_user_after_insert,
album_user_delete_audit,
memory_delete_audit,
@ -158,7 +161,7 @@ export class ImmichDatabase {
asset_face_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
}
export interface Migrations {

View File

@ -0,0 +1,92 @@
import { Kysely, sql } from 'kysely';
import { AlbumUserRole } from 'src/enum';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_user_after_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END
$$;`.execute(db);
await sql`DROP TRIGGER "album_delete_audit" ON "album";`.execute(db);
await sql`DROP FUNCTION album_delete_audit;`.execute(db);
await sql`CREATE TYPE "album_user_role_enum" AS ENUM ('owner','editor','viewer');`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE album_user_role_enum USING "role"::album_user_role_enum;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor'::album_user_role_enum;`.execute(db);
await db
.insertInto('album_user')
.expression((eb) =>
eb
.selectFrom('album')
.select(['album.id as albumId', 'album.ownerId as userId', eb.val(AlbumUserRole.Owner).as('role')]),
)
.execute();
await sql`ALTER TABLE "album" DROP CONSTRAINT "album_ownerId_fkey";`.execute(db);
await sql`ALTER TABLE "album" DROP COLUMN "ownerId";`.execute(db);
await sql`CREATE UNIQUE INDEX "album_user_unique_owner" ON "album_user" ("albumId") WHERE (role = 'owner');`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_after_insert","sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT \\"albumId\\" FROM inserted_rows)\\n AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = ''owner'');\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_album_user_unique_owner', '{"type":"index","name":"album_user_unique_owner","sql":"CREATE UNIQUE INDEX \\"album_user_unique_owner\\" ON \\"album_user\\" (\\"albumId\\") WHERE (role = ''owner'');"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_delete_audit';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.album_user_after_insert()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
RETURN NULL;
END
$function$
`.execute(db);
await sql`CREATE OR REPLACE FUNCTION public.album_delete_audit()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$function$
`.execute(db);
await sql`ALTER TABLE "album" ADD "ownerId" uuid NOT NULL;`.execute(db);
await db
.updateTable('album')
.set((eb) =>
({
id: eb.ref('album_user.albumId'),
ownerId: eb.ref('album_user.userId')
})
)
.from('album_user')
.where('album_user.role', '=', AlbumUserRole.Owner)
.execute();
await sql`DROP INDEX "album_user_unique_owner";`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE character varying USING "role"::text;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor';`.execute(db);
await sql`DROP TYPE "album_user_role_enum";`.execute(db);
await sql`CREATE INDEX "album_ownerId_idx" ON "album" ("ownerId");`.execute(db);
await sql`ALTER TABLE "album" ADD CONSTRAINT "album_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_delete_audit"
AFTER DELETE ON "album"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN ((pg_trigger_depth() = 0))
EXECUTE FUNCTION album_delete_audit();`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT DISTINCT \\"albumId\\" FROM inserted_rows);\\n RETURN NULL;\\n END\\n $$;","name":"album_user_after_insert","type":"function"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_delete_audit', '{"sql":"CREATE OR REPLACE FUNCTION album_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"album_delete_audit","type":"function"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_delete_audit', '{"sql":"CREATE OR REPLACE TRIGGER \\"album_delete_audit\\"\\n AFTER DELETE ON \\"album\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION album_delete_audit();","name":"album_delete_audit","type":"trigger"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_album_user_unique_owner';`.execute(db);
}

View File

@ -5,17 +5,25 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album_user' })
@Index({
name: 'album_user_unique_owner',
columns: ['albumId'],
unique: true,
where: `role = 'owner'`,
})
// Pre-existing indices from original album <--> user ManyToMany mapping
@UpdatedAtTrigger('album_user_updatedAt')
@AfterInsertTrigger({
@ -47,7 +55,7 @@ export class AlbumUserTable {
})
userId!: string;
@Column({ type: 'character varying', default: AlbumUserRole.Editor })
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@CreateIdColumn({ index: true })

View File

@ -1,5 +1,4 @@
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
DeleteDateColumn,
@ -12,25 +11,14 @@ import {
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { album_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album' })
@UpdatedAtTrigger('album_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: album_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AlbumTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: 'Untitled Album' })
albumName!: Generated<string>;

View File

@ -44,7 +44,8 @@ describe(AlbumService.name, () => {
describe('getAll', () => {
it('gets list of albums for auth user', async () => {
const album = AlbumFactory.from().albumUser().build();
const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build();
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -63,7 +64,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), {});
const result = await sut.getAll(AuthFactory.create(owner), {});
expect(result).toHaveLength(2);
expect(result[0].id).toEqual(album.id);
expect(result[1].id).toEqual(sharedWithUserAlbum.id);
@ -76,6 +77,7 @@ describe(AlbumService.name, () => {
.asset({}, (builder) => builder.exif())
.asset({}, (builder) => builder.exif())
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -87,7 +89,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), { assetId: album.assets[0].id });
const result = await sut.getAll(AuthFactory.create(owner), { assetId: album.assets[0].id });
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id);
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1);
@ -95,6 +97,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getShared.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -106,7 +109,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), { shared: true });
const result = await sut.getAll(AuthFactory.create(owner), { shared: true });
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id);
expect(mocks.album.getShared).toHaveBeenCalledTimes(1);
@ -114,6 +117,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -125,7 +129,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), { shared: false });
const result = await sut.getAll(AuthFactory.create(owner), { shared: false });
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id);
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1);
@ -134,6 +138,7 @@ describe(AlbumService.name, () => {
it('counts assets correctly', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -145,7 +150,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), {});
const result = await sut.getAll(AuthFactory.create(owner), {});
expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1);
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1);
@ -159,13 +164,14 @@ describe(AlbumService.name, () => {
.asset({ id: assetId }, (asset) => asset.exif())
.albumUser(albumUser)
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user));
mocks.user.getMetadata.mockResolvedValue([]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.create(AuthFactory.create(album.owner), {
await sut.create(AuthFactory.create(owner), {
albumName: 'test',
albumUsers: [albumUser],
description: 'description',
@ -174,20 +180,27 @@ describe(AlbumService.name, () => {
expect(mocks.album.create).toHaveBeenCalledWith(
{
ownerId: album.owner.id,
albumName: 'test',
description: 'description',
order: album.order,
albumThumbnailAssetId: assetId,
},
[assetId],
[{ userId: albumUser.userId, role: AlbumUserRole.Editor }],
[
{ userId: owner.id, role: AlbumUserRole.Owner },
{ userId: albumUser.userId, role: AlbumUserRole.Editor },
],
owner.id,
);
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId });
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: albumUser.userId,
senderName: owner.name,
});
});
it('creates album with assetOrder from user preferences', async () => {
@ -197,8 +210,10 @@ describe(AlbumService.name, () => {
.asset({ id: assetId }, (asset) => asset.exif())
.albumUser(albumUser)
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
mocks.albumUser.create.mockResolvedValue(album.albumUsers[0]);
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[1].user));
mocks.user.getMetadata.mockResolvedValue([
{
key: UserMetadataKey.Preferences,
@ -211,7 +226,7 @@ describe(AlbumService.name, () => {
]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.create(AuthFactory.create(album.owner), {
await sut.create(AuthFactory.create(owner), {
albumName: album.albumName,
albumUsers: [albumUser],
description: album.description,
@ -220,20 +235,24 @@ describe(AlbumService.name, () => {
expect(mocks.album.create).toHaveBeenCalledWith(
{
ownerId: album.owner.id,
albumName: album.albumName,
description: album.description,
order: 'asc',
albumThumbnailAssetId: assetId,
},
[assetId],
[albumUser],
[{ userId: owner.id, role: AlbumUserRole.Owner }, albumUser],
owner.id,
);
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId });
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: albumUser.userId,
senderName: owner.name,
});
});
it('should require valid userIds', async () => {
@ -254,12 +273,13 @@ describe(AlbumService.name, () => {
.asset({ id: assetId }, (asset) => asset.exif())
.albumUser()
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.getMetadata.mockResolvedValue([]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.create(AuthFactory.create(album.owner), {
await sut.create(AuthFactory.create(owner), {
albumName: album.albumName,
description: album.description,
assetIds: [assetId, 'asset-2'],
@ -267,29 +287,26 @@ describe(AlbumService.name, () => {
expect(mocks.album.create).toHaveBeenCalledWith(
{
ownerId: album.owner.id,
albumName: album.albumName,
description: album.description,
order: 'desc',
albumThumbnailAssetId: assetId,
},
[assetId],
[],
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
album.owner.id,
new Set([assetId, 'asset-2']),
false,
[{ userId: owner.id, role: AlbumUserRole.Owner }],
owner.id,
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId, 'asset-2']), false);
});
it('should throw an error if the userId is the ownerId', async () => {
const album = AlbumFactory.create();
mocks.user.get.mockResolvedValue(album.owner);
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.user.get.mockResolvedValue(owner);
await expect(
sut.create(AuthFactory.create(album.owner), {
sut.create(AuthFactory.create(owner), {
albumName: 'Empty album',
albumUsers: [{ userId: album.owner.id, role: AlbumUserRole.Editor }],
albumUsers: [{ userId: owner.id, role: AlbumUserRole.Editor }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.create).not.toHaveBeenCalled();
@ -312,20 +329,22 @@ describe(AlbumService.name, () => {
it('should prevent updating a not owned album (shared with auth user)', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(
sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }),
sut.update(AuthFactory.create(owner), album.id, { albumName: 'new album name' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require a valid thumbnail asset id', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set());
await expect(
sut.update(AuthFactory.create(album.owner), album.id, { albumThumbnailAssetId: 'not-in-album' }),
sut.update(AuthFactory.create(owner), album.id, { albumThumbnailAssetId: 'not-in-album' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.getAssetIds).toHaveBeenCalledWith(album.id, ['not-in-album']);
@ -334,43 +353,51 @@ describe(AlbumService.name, () => {
it('should allow the owner to update the album', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.update.mockResolvedValue(getForAlbum(album));
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
await sut.update(AuthFactory.create(owner), album.id, { albumName: 'new album name' });
expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id, albumName: 'new album name' });
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{ id: album.id, albumName: 'new album name' },
owner.id,
);
});
});
describe('delete', () => {
it('should require permissions', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.delete).not.toHaveBeenCalled();
});
it('should not let a shared user delete the album', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.delete).not.toHaveBeenCalled();
});
it('should let the owner delete an album', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await sut.delete(AuthFactory.create(album.owner), album.id);
await sut.delete(AuthFactory.create(owner), album.id);
expect(mocks.album.delete).toHaveBeenCalledTimes(1);
expect(mocks.album.delete).toHaveBeenCalledWith(album.id);
@ -391,10 +418,11 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId is already added', async () => {
const userId = newUuid();
const album = AlbumFactory.from().albumUser({ userId }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }),
sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId }] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.user.get).not.toHaveBeenCalled();
@ -402,11 +430,12 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId does not exist', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(void 0);
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.user.get).toHaveBeenCalledWith('unknown-user', {});
@ -414,11 +443,12 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId is the ownerId', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, {
albumUsers: [{ userId: album.owner.id }],
sut.addUsers(AuthFactory.create(owner), album.id, {
albumUsers: [{ userId: owner.id }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled();
@ -427,6 +457,7 @@ describe(AlbumService.name, () => {
it('should add valid shared users', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const user = UserFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
@ -434,7 +465,7 @@ describe(AlbumService.name, () => {
mocks.user.get.mockResolvedValue(user);
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
await sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: user.id }] });
await sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: user.id }] });
expect(mocks.albumUser.create).toHaveBeenCalledWith({
userId: user.id,
@ -443,6 +474,7 @@ describe(AlbumService.name, () => {
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: user.id,
senderName: owner.name,
});
});
});
@ -460,15 +492,16 @@ describe(AlbumService.name, () => {
it('should remove a shared user from an owned album', async () => {
const userId = newUuid();
const album = AlbumFactory.from().albumUser({ userId }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.delete.mockResolvedValue();
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined();
await expect(sut.removeUser(AuthFactory.create(owner), album.id, userId)).resolves.toBeUndefined();
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, owner.id);
});
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
@ -511,9 +544,10 @@ describe(AlbumService.name, () => {
it('should not allow the owner to be removed', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf(
await expect(sut.removeUser(AuthFactory.create(owner), album.id, owner.id)).rejects.toBeInstanceOf(
BadRequestException,
);
@ -522,9 +556,10 @@ describe(AlbumService.name, () => {
it('should throw an error for a user not in the album', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf(
await expect(sut.removeUser(AuthFactory.create(owner), album.id, 'user-3')).rejects.toBeInstanceOf(
BadRequestException,
);
@ -536,10 +571,11 @@ describe(AlbumService.name, () => {
it('should update user role', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.albumUser.update.mockResolvedValue();
await sut.updateUser(AuthFactory.create(album.owner), album.id, user.id, { role: AlbumUserRole.Viewer });
await sut.updateUser(AuthFactory.create(owner), album.id, user.id, { role: AlbumUserRole.Viewer });
expect(mocks.albumUser.update).toHaveBeenCalledWith(
{ albumId: album.id, userId: user.id },
@ -551,6 +587,7 @@ describe(AlbumService.name, () => {
describe('getAlbumInfo', () => {
it('should get a shared album', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getMetadataForIds.mockResolvedValue([
@ -563,10 +600,10 @@ describe(AlbumService.name, () => {
},
]);
await sut.get(AuthFactory.create(album.owner), album.id);
await sut.get(AuthFactory.create(owner), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id]));
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, owner.id);
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([album.id]));
});
it('should get a shared album via a shared link', async () => {
@ -586,7 +623,7 @@ describe(AlbumService.name, () => {
const auth = AuthFactory.from().sharedLink().build();
await sut.get(auth, album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, auth.user.id);
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id]));
});
@ -607,7 +644,7 @@ describe(AlbumService.name, () => {
await sut.get(AuthFactory.create(user), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, user.id);
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
user.id,
new Set([album.id]),
@ -631,7 +668,7 @@ describe(AlbumService.name, () => {
describe('addAssets', () => {
it('should allow the owner to add assets', async () => {
const owner = UserFactory.create({ isAdmin: true });
const album = AlbumFactory.from({ ownerId: owner.id }).owner(owner).build();
const album = AlbumFactory.from().owner(owner).build();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
@ -646,37 +683,47 @@ describe(AlbumService.name, () => {
{ success: true, id: asset3.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
});
it('should not set the thumbnail if the album has one already', async () => {
const [asset1, asset2] = [AssetFactory.create(), AssetFactory.create()];
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
{ success: true, id: asset2.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalled();
});
it('should allow a shared user to add assets', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
@ -691,15 +738,19 @@ describe(AlbumService.name, () => {
{ success: true, id: asset3.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
user.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album.id,
recipientId: album.ownerId,
recipientId: owner.id,
});
});
@ -719,33 +770,39 @@ describe(AlbumService.name, () => {
it('should allow adding assets shared via partner sharing', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const asset = AssetFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
});
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]));
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
},
owner.id,
);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
});
it('should skip duplicate assets', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id]));
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: false, id: asset.id, error: BulkIdErrorReason.DUPLICATE },
]);
@ -755,16 +812,17 @@ describe(AlbumService.name, () => {
it('should skip assets not shared with user', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: false, id: asset.id, error: BulkIdErrorReason.NO_PERMISSION },
]);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]), false);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]));
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]), false);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
});
it('should not allow unauthorized access to the album', async () => {
@ -797,6 +855,7 @@ describe(AlbumService.name, () => {
describe('addAssetsToAlbums', () => {
it('should allow the owner to add assets', async () => {
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
@ -805,23 +864,33 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@ -835,6 +904,7 @@ describe(AlbumService.name, () => {
it('should not set the thumbnail if the album has one already', async () => {
const asset = AssetFactory.create();
const album1 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
@ -843,23 +913,33 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
},
owner.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@ -873,7 +953,9 @@ describe(AlbumService.name, () => {
it('should allow a shared user to add assets', async () => {
const user = UserFactory.create();
const album1 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build();
const { user: owner1 } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build();
const { user: owner2 } = album2.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
@ -888,16 +970,26 @@ describe(AlbumService.name, () => {
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
user.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
user.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@ -908,11 +1000,11 @@ describe(AlbumService.name, () => {
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album1.id,
recipientId: album1.ownerId,
recipientId: owner1.id,
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album2.id,
recipientId: album2.ownerId,
recipientId: owner2.id,
});
});
@ -942,6 +1034,7 @@ describe(AlbumService.name, () => {
it('should allow adding assets shared via partner sharing', async () => {
const user = UserFactory.create();
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [
AssetFactory.create({ ownerId: user.id }),
@ -954,23 +1047,33 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@ -980,7 +1083,7 @@ describe(AlbumService.name, () => {
{ albumId: album2.id, assetId: asset3.id },
]);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
album1.ownerId,
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
);
});
@ -988,7 +1091,9 @@ describe(AlbumService.name, () => {
it('should skip some duplicate assets', async () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getAssetIds
@ -997,18 +1102,23 @@ describe(AlbumService.name, () => {
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album2.id, assetId: asset1.id },
{ albumId: album2.id, assetId: asset2.id },
@ -1019,6 +1129,7 @@ describe(AlbumService.name, () => {
it('should skip all duplicate assets', async () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
mocks.access.album.checkOwnerAccess
.mockResolvedValueOnce(new Set([album1.id]))
@ -1028,7 +1139,7 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
@ -1044,6 +1155,7 @@ describe(AlbumService.name, () => {
it('should skip assets not shared with user', async () => {
const user = UserFactory.create();
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [
AssetFactory.create({ ownerId: user.id }),
@ -1057,7 +1169,7 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
@ -1069,12 +1181,12 @@ describe(AlbumService.name, () => {
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
album1.ownerId,
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
false,
);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
album1.ownerId,
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
);
});
@ -1126,12 +1238,13 @@ describe(AlbumService.name, () => {
it('should allow the owner to remove assets', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
@ -1141,11 +1254,12 @@ describe(AlbumService.name, () => {
it('should skip assets not in the album', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: false, id: asset.id, error: BulkIdErrorReason.NOT_FOUND },
]);
@ -1155,11 +1269,12 @@ describe(AlbumService.name, () => {
it('should allow owner to remove all assets from the album', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
});
@ -1168,12 +1283,13 @@ describe(AlbumService.name, () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset1.id] })).resolves.toEqual([
{ success: true, id: asset1.id },
]);

View File

@ -15,7 +15,7 @@ import {
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { Permission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@ -74,10 +74,10 @@ export class AlbumService extends BaseService {
async get(auth: AuthDto, id: string): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
await this.albumRepository.updateThumbnails();
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 1;
const hasSharedLink = album.sharedLinks && album.sharedLinks.length > 0;
const isShared = hasSharedUsers || hasSharedLink;
@ -114,6 +114,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Cannot share album with owner');
}
}
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
const allowedAssetIdsSet = await this.checkAccess({
auth,
@ -126,7 +127,6 @@ export class AlbumService extends BaseService {
const album = await this.albumRepository.create(
{
ownerId: auth.user.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: assetIds[0] || null,
@ -134,10 +134,11 @@ export class AlbumService extends BaseService {
},
assetIds,
albumUsers,
auth.user.id,
);
for (const { userId } of albumUsers) {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId });
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
return mapAlbum(album);
@ -146,7 +147,7 @@ export class AlbumService extends BaseService {
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
const album = await this.findOrFail(id, { withAssets: true });
const album = await this.findOrFail(id, auth.user.id, { withAssets: true });
if (dto.albumThumbnailAssetId) {
const results = await this.albumRepository.getAssetIds(id, [dto.albumThumbnailAssetId]);
@ -154,14 +155,18 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update(album.id, {
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
});
const updatedAlbum = await this.albumRepository.update(
album.id,
{
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
},
auth.user.id,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
}
@ -172,7 +177,7 @@ export class AlbumService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
const results = await addAssets(
@ -183,16 +188,18 @@ export class AlbumService extends BaseService {
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
if (firstNewAssetId) {
await this.albumRepository.update(id, {
await this.albumRepository.update(
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
{
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
}
@ -231,21 +238,23 @@ export class AlbumService extends BaseService {
if (notPresentAssetIds.length === 0) {
continue;
}
const album = await this.findOrFail(albumId, { withAssets: false });
const album = await this.findOrFail(albumId, auth.user.id, { withAssets: false });
results.error = undefined;
results.success = true;
for (const assetId of notPresentAssetIds) {
albumAssetValues.push({ albumId, assetId });
}
await this.albumRepository.update(albumId, {
id: albumId,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
await this.albumRepository.update(
albumId,
{
id: albumId,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, recipients: allUsersExceptUs });
}
@ -262,7 +271,7 @@ export class AlbumService extends BaseService {
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const results = await removeAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
@ -280,11 +289,11 @@ export class AlbumService extends BaseService {
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
for (const { userId, role } of albumUsers) {
if (album.ownerId === userId) {
throw new BadRequestException('Cannot be shared with owner');
if (role === AlbumUserRole.Owner) {
throw new BadRequestException('Cannot add another owner');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
@ -298,10 +307,10 @@ export class AlbumService extends BaseService {
}
await this.albumUserRepository.create({ userId, albumId: id, role });
await this.eventRepository.emit('AlbumInvite', { id, userId });
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return this.findOrFail(id, { withAssets: true }).then(mapAlbum);
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@ -309,17 +318,20 @@ export class AlbumService extends BaseService {
userId = auth.user.id;
}
const album = await this.findOrFail(id, { withAssets: false });
if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner');
}
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (!exists) {
throw new BadRequestException('Album not shared with user');
}
if (
exists.role === AlbumUserRole.Owner &&
album.albumUsers.filter(({ role }) => role === AlbumUserRole.Owner).length === 1
) {
throw new BadRequestException('Cannot remove the last album owner');
}
// non-admin can remove themselves
if (auth.user.id !== userId) {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
@ -333,8 +345,8 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
private async findOrFail(id: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options);
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options, authUserId);
if (!album) {
throw new BadRequestException('Album not found');
}

View File

@ -168,10 +168,10 @@ describe(NotificationService.name, () => {
describe('onAlbumInviteEvent', () => {
it('should queue notify album invite event', async () => {
await sut.onAlbumInvite({ id: '', userId: '42' });
await sut.onAlbumInvite({ id: '', userId: '42', senderName: 'foo' });
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NotifyAlbumInvite,
data: { id: '', recipientId: '42' },
data: { id: '', recipientId: '42', senderName: 'foo' },
});
});
});
@ -264,14 +264,18 @@ describe(NotificationService.name, () => {
describe('handleAlbumInvite', () => {
it('should skip if album could not be found', async () => {
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
expect(mocks.user.get).not.toHaveBeenCalled();
});
it('should skip if recipient could not be found', async () => {
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
expect(mocks.job.queue).not.toHaveBeenCalled();
});
@ -288,7 +292,9 @@ describe(NotificationService.name, () => {
});
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
});
it('should skip if the recipient has email notifications for album invite disabled', async () => {
@ -304,7 +310,9 @@ describe(NotificationService.name, () => {
});
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
});
it('should send invite email', async () => {
@ -322,7 +330,9 @@ describe(NotificationService.name, () => {
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.SendMail,
data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }),
@ -346,7 +356,9 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
album.albumThumbnailAssetId,
AssetFileType.Thumbnail,
@ -378,7 +390,9 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetFile]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
album.albumThumbnailAssetId,
AssetFileType.Thumbnail,
@ -412,7 +426,9 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([asset.files[0]]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
album.albumThumbnailAssetId,
AssetFileType.Thumbnail,
@ -434,7 +450,7 @@ describe(NotificationService.name, () => {
});
it('should skip if owner could not be found', async () => {
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create({ ownerId: 'non-existent' })));
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.from().owner({ id: 'non-existent' }).build()));
await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
@ -443,7 +459,6 @@ describe(NotificationService.name, () => {
it('should skip recipient that could not be looked up', async () => {
const album = AlbumFactory.from().albumUser({ userId: 'non-existent' }).build();
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValueOnce(album.owner);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);

View File

@ -226,8 +226,8 @@ export class NotificationService extends BaseService {
}
@OnEvent({ name: 'AlbumInvite' })
async onAlbumInvite({ id, userId }: ArgOf<'AlbumInvite'>) {
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId } });
async onAlbumInvite({ id, userId, senderName }: ArgOf<'AlbumInvite'>) {
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId, senderName } });
}
@OnEvent({ name: 'SessionDelete' })
@ -303,7 +303,7 @@ export class NotificationService extends BaseService {
}
@OnJob({ name: JobName.NotifyAlbumInvite, queue: QueueName.Notification })
async handleAlbumInvite({ id, recipientId }: JobOf<JobName.NotifyAlbumInvite>) {
async handleAlbumInvite({ id, recipientId, senderName }: JobOf<JobName.NotifyAlbumInvite>) {
const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) {
return JobStatus.Skipped;
@ -314,7 +314,7 @@ export class NotificationService extends BaseService {
return JobStatus.Skipped;
}
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, album.owner.name);
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, senderName);
const { emailNotifications } = getPreferences(recipient.metadata);
@ -331,7 +331,7 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server),
albumId: album.id,
albumName: album.albumName,
senderName: album.owner.name,
senderName,
recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined,
},
@ -360,8 +360,8 @@ export class NotificationService extends BaseService {
return JobStatus.Skipped;
}
const owner = await this.userRepository.get(album.ownerId, { withDeleted: false });
if (!owner) {
const recipient = await this.userRepository.get(recipientId, { withDeleted: false });
if (!recipient) {
return JobStatus.Skipped;
}

View File

@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import {
SyncAckDeleteDto,
SyncAckSetDto,
syncAlbumV2ToV1,
syncAssetFaceV2ToV1,
SyncAssetV1,
SyncItem,
@ -60,6 +61,7 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.PartnerStacksV1,
SyncRequestType.AlbumAssetsV1,
SyncRequestType.AlbumsV1,
SyncRequestType.AlbumsV2,
SyncRequestType.AlbumUsersV1,
SyncRequestType.AlbumToAssetsV1,
SyncRequestType.AssetExifsV1,
@ -165,6 +167,7 @@ export class SyncService extends BaseService {
[SyncRequestType.PartnerAssetExifsV1]: () =>
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
[SyncRequestType.AlbumsV2]: () => this.syncAlbumsV2(options, response, checkpointMap),
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id),
@ -412,6 +415,21 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.AlbumV1;
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
const albumUsers = await this.syncRepository.album.getAlbumUsers(data.id);
send(response, { type: upsertType, ids: [updateId], data: syncAlbumV2ToV1(data, albumUsers) });
}
}
private async syncAlbumsV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.AlbumDeleteV1;
const deletes = this.syncRepository.album.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AlbumV2;
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}

View File

@ -249,6 +249,7 @@ export interface INotifySignupJob extends IEntityJob {
export interface INotifyAlbumInviteJob extends IEntityJob {
recipientId: string;
senderName: string;
}
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {

View File

@ -455,3 +455,5 @@ export const updateLockedColumns = <T extends Record<string, unknown> & { locked
exif.lockedProperties = lockableProperties.filter((property) => property in exif);
return exif;
};
export const dummy = sql`(select 1)`.as('dummy');

View File

@ -1,5 +1,5 @@
import { Selectable } from 'kysely';
import { AssetOrder } from 'src/enum';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumUserFactory } from 'test/factories/album-user.factory';
@ -10,15 +10,12 @@ import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AlbumFactory {
#owner: UserFactory;
#owner!: UserFactory;
#sharedLinks: Selectable<SharedLinkTable>[] = [];
#albumUsers: AlbumUserFactory[] = [];
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<AlbumTable>) {
value.ownerId ??= newUuid();
this.#owner = UserFactory.from({ id: value.ownerId });
}
private constructor(private readonly value: Selectable<AlbumTable>) {}
static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build();
@ -27,7 +24,6 @@ export class AlbumFactory {
static from(dto: AlbumLike = {}) {
return new AlbumFactory({
id: newUuid(),
ownerId: newUuid(),
albumName: 'My Album',
albumThumbnailAssetId: null,
createdAt: newDate(),
@ -43,7 +39,7 @@ export class AlbumFactory {
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#owner = build(UserFactory.from(dto), builder);
this.value.ownerId = this.#owner.build().id;
this.albumUser({ userId: this.#owner.build().id, role: AlbumUserRole.Owner });
return this;
}
@ -53,7 +49,7 @@ export class AlbumFactory {
}
albumUser(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumUserFactory>) {
const albumUser = build(AlbumUserFactory.from(dto).album(this.value), builder);
const albumUser = build(AlbumUserFactory.from(dto), builder);
this.#albumUsers.push(albumUser);
return this;
}
@ -78,7 +74,6 @@ export class AlbumFactory {
build() {
return {
...this.value,
owner: this.#owner.build(),
assets: this.#assets.map((asset) => asset.build()),
albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()),
sharedLinks: this.#sharedLinks,

View File

@ -84,7 +84,6 @@ export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
createdAt: albumUser.createdAt.toISOString(),
user: getDehydrated(albumUser.user),
})),
owner: getDehydrated(album.owner),
sharedLinks: album.sharedLinks.map((sharedLink) => getDehydrated(sharedLink)),
});
@ -219,7 +218,6 @@ export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build
album: sharedLink.album
? {
...getDehydrated(sharedLink.album),
owner: getDehydrated(sharedLink.album.owner),
assets: sharedLink.album.assets.map((asset) => getDehydrated(asset)),
}
: null,

View File

@ -222,9 +222,14 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { result };
}
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
async newAlbum({ ownerId, ...dto }: Insertable<AlbumTable> & { ownerId: string }, assetIds?: string[]) {
const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
const result = await this.get(AlbumRepository).create(
album,
assetIds ?? [],
[{ userId: ownerId, role: AlbumUserRole.Owner }],
ownerId,
);
return { album, result };
}
@ -570,9 +575,9 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
};
};
const albumInsert = (album: Partial<Insertable<AlbumTable>> & { ownerId: string }) => {
const albumInsert = (album: Partial<Insertable<AlbumTable>>) => {
const id = album.id || newUuid();
const defaults: Omit<Insertable<AlbumTable>, 'ownerId'> = {
const defaults: Insertable<AlbumTable> = {
albumName: 'Album',
};

View File

@ -25,6 +25,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Owner,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@ -47,6 +55,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Owner,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@ -136,6 +152,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Owner,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@ -163,6 +187,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@ -201,6 +226,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@ -229,10 +255,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
it('should backfill album users when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user } = await ctx.newUser();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { album: album1 } = await ctx.newAlbum({ ownerId: user1.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user1.id });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
// backfill album user
await ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.Editor });
await wait(2);
@ -244,6 +271,15 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
role: AlbumUserRole.Owner,
userId: user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@ -264,6 +300,15 @@ describe(SyncRequestType.AlbumUsersV1, () => {
// should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.Owner,
userId: user.id,
}),
type: SyncEntityType.AlbumUserBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({

View File

@ -30,7 +30,6 @@ describe(SyncRequestType.AlbumsV1, () => {
data: expect.objectContaining({
id: album.id,
name: album.albumName,
ownerId: album.ownerId,
}),
type: SyncEntityType.AlbumV1,
},

View File

@ -3,7 +3,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time';
import type { AlbumResponseDto } from '@immich/sdk';
import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -85,12 +85,13 @@
{/if}
{#if showOwner}
{#if authManager.user.id === album.ownerId}
{@const owner = album.albumUsers[0].user}
{#if owner.id === authManager.user.id}
<p>{$t('owned')}</p>
{:else if album.owner}
<p>{$t('shared_by_user', { values: { user: album.owner.name } })}</p>
{:else}
<p>{$t('shared')}</p>
<p>
{$t('shared_by_user', { values: { user: owner.name } })}
</p>
{/if}
{:else if album.shared}
<p>{$t('shared')}</p>

View File

@ -20,7 +20,7 @@
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AlbumUserRole, type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
@ -98,24 +98,26 @@
/** Group by owner */
[AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => {
const currentUserId = authManager.user.id;
const groupedByOwnerIds = groupBy(albums, 'ownerId');
const groupedByOwnerIds = groupBy(albums, (album) => album.albumUsers[0].user.id);
const sortSign = order === SortOrder.Desc ? -1 : 1;
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerA, albumsA], [ownerB, albumsB]) => {
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerIdA, albumsA], [ownerIdB, albumsB]) => {
// We make sure owned albums stay either at the beginning or the end
// of the list
if (ownerA === currentUserId) {
if (ownerIdA === currentUserId) {
return -sortSign;
} else if (ownerB === currentUserId) {
} else if (ownerIdB === currentUserId) {
return sortSign;
} else {
return albumsA[0].owner.name.localeCompare(albumsB[0].owner.name, $locale) * sortSign;
const ownerA = albumsA[0].albumUsers[0].user;
const ownerB = albumsB[0].albumUsers[0].user;
return ownerA.name.localeCompare(ownerB.name, $locale) * sortSign;
}
});
return sortedByOwnerNames.map(([ownerId, albums]) => ({
id: ownerId,
name: ownerId === currentUserId ? $t('my_albums') : albums[0].owner.name,
name: ownerId === currentUserId ? $t('my_albums') : albums[0].albumUsers[0].user.name,
albums,
}));
},
@ -130,7 +132,10 @@
return sharedAlbums;
}
default: {
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== authManager.user.id);
const nonOwnedAlbums = sharedAlbums.filter(
(album) =>
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role !== AlbumUserRole.Owner,
);
return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
@ -167,7 +172,9 @@
albumGroupIds = groupedAlbums.map(({ id }) => id);
});
let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === authManager.user.id);
let showFullContextMenu = $derived(
allowEdit && selectedAlbum && selectedAlbum.albumUsers[0].user.id === authManager.user.id,
);
onMount(async () => {
if (allowEdit) {

View File

@ -5,7 +5,7 @@
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import type { AlbumResponseDto } from '@immich/sdk';
import { AlbumUserRole, type AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -43,9 +43,11 @@
icon={mdiShareVariantOutline}
size="16"
class="inline ms-1 opacity-70"
title={album.ownerId === authManager.user.id
title={album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role === AlbumUserRole.Owner
? $t('shared_by_you')
: $t('shared_by_user', { values: { user: album.owner.name } })}
: $t('shared_by_user', {
values: { user: album.albumUsers[0].user.name },
})}
/>
{/if}
</td>

View File

@ -12,7 +12,7 @@
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum } from '@immich/sdk';
import { ReactionType, type ActivityResponseDto, type AlbumUserResponseDto, type AssetTypeEnum } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
import * as luxon from 'luxon';
@ -43,11 +43,11 @@
assetId?: string | undefined;
albumId: string;
assetType?: AssetTypeEnum | undefined;
albumOwnerId: string;
albumUsers: AlbumUserResponseDto[];
disabled: boolean;
}
let { assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
let { assetId = undefined, albumId, assetType = undefined, albumUsers, disabled }: Props = $props();
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
@ -56,6 +56,7 @@
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
const isAlbumOwner = $derived(albumUsers[0].user.id === authManager.user.id);
const timeOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
@ -147,7 +148,7 @@
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}
@ -200,7 +201,7 @@
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}

View File

@ -638,7 +638,7 @@
<ActivityViewer
disabled={!album.isActivityEnabled}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumUsers={album.albumUsers}
albumId={album.id}
assetId={asset.id}
/>

View File

@ -82,7 +82,7 @@
}: Props = $props();
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.albumUsers[0].user.id === authManager.user.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);

View File

@ -20,7 +20,7 @@
const { album, onClose }: Props = $props();
let users: UserResponseDto[] = $state([]);
const excludedUserIds = $derived([album.ownerId, ...album.albumUsers.map(({ user: { id } }) => id)]);
const excludedUserIds = $derived(album.albumUsers.map(({ user: { id } }) => id));
const filteredUsers = $derived(
sortBy(
users.filter(

View File

@ -105,16 +105,6 @@
<HeaderActionButton action={AddUsers} />
</HStack>
<div class="ps-2">
<div class="flex items-center gap-2 mb-2">
<div>
<UserAvatar user={album.owner} size="md" />
</div>
<Text class="w-full" size="small">{album.owner.name}</Text>
<Field disabled class="w-32 shrink-0">
<Select options={[{ label: $t('owner'), value: 'owner' }]} value="owner" />
</Field>
</div>
{#each album.albumUsers as { user, role } (user.id)}
<div class="flex items-center justify-between gap-4 py-2">
<div class="flex flex-row items-center gap-2">
@ -123,12 +113,13 @@
</div>
<Text size="small">{user.name}</Text>
</div>
<Field class="w-32">
<Field class="w-32" disabled={role === AlbumUserRole.Owner}>
<Select
value={role}
options={[
{ label: $t('role_editor'), value: AlbumUserRole.Editor },
{ label: $t('role_viewer'), value: AlbumUserRole.Viewer },
{ label: $t('owner'), value: AlbumUserRole.Owner },
{ label: $t('remove_user'), value: 'none' },
] as SelectOption<AlbumUserRole | 'none'>[]}
onChange={(value) => handleRoleSelect(user, value)}

View File

@ -42,7 +42,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
const isOwned = authManager.user.id === album.ownerId;
const isOwned = album.albumUsers[0].user.id === authManager.user.id;
const Share: ActionItem = {
title: $t('share'),

View File

@ -210,9 +210,7 @@
let albumId = $derived(album.id);
const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor));
const albumUsers = $derived(
showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [],
);
const albumUsers = $derived(showAlbumUsers && containsEditors ? album.albumUsers.map(({ user }) => user) : []);
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
@ -231,7 +229,7 @@
return { albumId, order: album.order };
});
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 1);
$effect(() => {
if (assetViewerManager.isViewing || !isShared) {
@ -243,16 +241,15 @@
onDestroy(() => activityManager.reset());
let isOwned = $derived(authManager.user.id == album.ownerId);
const isOwned = $derived(album.albumUsers[0].user.id === authManager.user.id);
let showActivityStatus = $derived(
album.albumUsers.length > 0 &&
album.albumUsers.length > 1 &&
!assetViewerManager.isViewing &&
(album.isActivityEnabled || activityManager.commentCount > 0),
);
let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role === AlbumUserRole.Editor ||
album.ownerId === authManager.user.id,
const isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role === AlbumUserRole.Editor || isOwned,
);
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
@ -374,7 +371,7 @@
{/if}
<!-- ALBUM SHARING -->
{#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)}
{#if album.albumUsers.length > 1 || (album.hasSharedLink && isOwned)}
<div class="my-3 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
@ -388,13 +385,8 @@
/>
{/if}
<!-- owner -->
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
<UserAvatar user={album.owner} size="md" />
</button>
<!-- users with write access (collaborators) -->
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor || role === AlbumUserRole.Owner) as { user } (user.id)}
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
<UserAvatar {user} size="md" />
</button>
@ -620,7 +612,7 @@
{/if}
{/if}
</div>
{#if album.albumUsers.length > 0 && album && assetViewerManager.isShowActivityPanel && authManager.authenticated && !assetViewerManager.isViewing}
{#if album.albumUsers.length > 1 && album && assetViewerManager.isShowActivityPanel && authManager.authenticated && !assetViewerManager.isViewing}
<div class="flex">
<div
transition:fly={{ duration: 150 }}
@ -628,7 +620,7 @@
class="z-2 w-90 md:w-115 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<ActivityViewer disabled={!album.isActivityEnabled} albumOwnerId={album.ownerId} albumId={album.id} />
<ActivityViewer disabled={!album.isActivityEnabled} albumUsers={album.albumUsers} albumId={album.id} />
</div>
</div>
{/if}

View File

@ -1,7 +1,6 @@
import { faker } from '@faker-js/faker';
import { AssetOrder, type AlbumResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
import { userFactory } from './user-factory';
export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
albumName: Sync.each(() => faker.commerce.product()),
@ -11,8 +10,6 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
createdAt: Sync.each(() => faker.date.past().toISOString()),
updatedAt: Sync.each(() => faker.date.past().toISOString()),
id: Sync.each(() => faker.string.uuid()),
ownerId: Sync.each(() => faker.string.uuid()),
owner: userFactory.build(),
shared: false,
albumUsers: [],
hasSharedLink: false,