diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 9265d439fc..ec6e3f7243 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -331,6 +331,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'id': string; + /** + * + * @type {boolean} + * @memberof AlbumResponseDto + */ + 'isActivityEnabled': boolean; /** * * @type {string} @@ -4160,6 +4166,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'description'?: string; + /** + * + * @type {boolean} + * @memberof UpdateAlbumDto + */ + 'isActivityEnabled'?: boolean; } /** * diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index 93620b9fc6..bc00d30af1 100644 Binary files a/mobile/openapi/doc/AlbumResponseDto.md and b/mobile/openapi/doc/AlbumResponseDto.md differ diff --git a/mobile/openapi/doc/UpdateAlbumDto.md b/mobile/openapi/doc/UpdateAlbumDto.md index 283b8bc29a..4ded87d1bf 100644 Binary files a/mobile/openapi/doc/UpdateAlbumDto.md and b/mobile/openapi/doc/UpdateAlbumDto.md differ diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index cf2ad92523..86e009e330 100644 Binary files a/mobile/openapi/lib/model/album_response_dto.dart and b/mobile/openapi/lib/model/album_response_dto.dart differ diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index 6c0bf3eca6..32d4d2a608 100644 Binary files a/mobile/openapi/lib/model/update_album_dto.dart and b/mobile/openapi/lib/model/update_album_dto.dart differ diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index c841742005..933f77c196 100644 Binary files a/mobile/openapi/test/album_response_dto_test.dart and b/mobile/openapi/test/album_response_dto_test.dart differ diff --git a/mobile/openapi/test/update_album_dto_test.dart b/mobile/openapi/test/update_album_dto_test.dart index 7b8472ad3e..67ec80010d 100644 Binary files a/mobile/openapi/test/update_album_dto_test.dart and b/mobile/openapi/test/update_album_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 05aec878ad..7e4dc7cdbd 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -5894,6 +5894,9 @@ "id": { "type": "string" }, + "isActivityEnabled": { + "type": "boolean" + }, "lastModifiedAssetTimestamp": { "format": "date-time", "type": "string" @@ -5935,7 +5938,8 @@ "sharedUsers", "hasSharedLink", "assets", - "owner" + "owner", + "isActivityEnabled" ], "type": "object" }, @@ -8910,6 +8914,9 @@ }, "description": { "type": "string" + }, + "isActivityEnabled": { + "type": "boolean" } }, "type": "object" diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 4142527784..88abd79b19 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -138,10 +138,7 @@ export class AccessCore { switch (permission) { // uses album id case Permission.ACTIVITY_CREATE: - return ( - (await this.repository.album.hasOwnerAccess(authUser.id, id)) || - (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) - ); + return await this.repository.activity.hasCreateAccess(authUser.id, id); // uses activity id case Permission.ACTIVITY_DELETE: diff --git a/server/src/domain/activity/activity.spec.ts b/server/src/domain/activity/activity.spec.ts index 968f7421a3..496d8978b7 100644 --- a/server/src/domain/activity/activity.spec.ts +++ b/server/src/domain/activity/activity.spec.ts @@ -94,7 +94,7 @@ describe(ActivityService.name, () => { }); it('should create a comment', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.activity.hasCreateAccess.mockResolvedValue(true); activityMock.create.mockResolvedValue(activityStub.oneComment); await sut.create(authStub.admin, { @@ -113,8 +113,23 @@ describe(ActivityService.name, () => { }); }); - it('should create a like', async () => { + it('should fail because activity is disabled for the album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.activity.hasCreateAccess.mockResolvedValue(false); + activityMock.create.mockResolvedValue(activityStub.oneComment); + + await expect( + sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.COMMENT, + comment: 'comment', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should create a like', async () => { + accessMock.activity.hasCreateAccess.mockResolvedValue(true); activityMock.create.mockResolvedValue(activityStub.liked); activityMock.search.mockResolvedValue([]); @@ -134,6 +149,7 @@ describe(ActivityService.name, () => { it('should skip if like exists', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.activity.hasCreateAccess.mockResolvedValue(true); activityMock.search.mockResolvedValue([activityStub.liked]); await sut.create(authStub.admin, { diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index b426bc37d6..671922408e 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -21,6 +21,7 @@ export class AlbumResponseDto { lastModifiedAssetTimestamp?: Date; startDate?: Date; endDate?: Date; + isActivityEnabled!: boolean; } export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { @@ -61,6 +62,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons endDate, assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)), assetCount: entity.assets?.length || 0, + isActivityEnabled: entity.isActivityEnabled, }; }; diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index b8e789943e..37d44c33ac 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -125,12 +125,12 @@ export class AlbumService { throw new BadRequestException('Invalid album thumbnail'); } } - const updatedAlbum = await this.albumRepository.update({ id: album.id, albumName: dto.albumName, description: dto.description, albumThumbnailAssetId: dto.albumThumbnailAssetId, + isActivityEnabled: dto.isActivityEnabled, }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } }); diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index f574f2c23c..3b1858ba10 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,4 +1,4 @@ -import { IsString } from 'class-validator'; +import { IsBoolean, IsString } from 'class-validator'; import { Optional, ValidateUUID } from '../../domain.util'; export class UpdateAlbumDto { @@ -12,4 +12,8 @@ export class UpdateAlbumDto { @ValidateUUID({ optional: true }) albumThumbnailAssetId?: string; + + @Optional() + @IsBoolean() + isActivityEnabled?: boolean; } diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 43b53e605d..f9ceb6f520 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -2,8 +2,9 @@ export const IAccessRepository = 'IAccessRepository'; export interface IAccessRepository { activity: { - hasOwnerAccess(userId: string, albumId: string): Promise; - hasAlbumOwnerAccess(userId: string, albumId: string): Promise; + hasOwnerAccess(userId: string, activityId: string): Promise; + hasAlbumOwnerAccess(userId: string, activityId: string): Promise; + hasCreateAccess(userId: string, albumId: string): Promise; }; asset: { hasOwnerAccess(userId: string, assetId: string): Promise; diff --git a/server/src/infra/entities/album.entity.ts b/server/src/infra/entities/album.entity.ts index 38ce4310c9..fbc125351a 100644 --- a/server/src/infra/entities/album.entity.ts +++ b/server/src/infra/entities/album.entity.ts @@ -56,4 +56,7 @@ export class AlbumEntity { @OneToMany(() => SharedLinkEntity, (link) => link.album) sharedLinks!: SharedLinkEntity[]; + + @Column({ default: true }) + isActivityEnabled!: boolean; } diff --git a/server/src/infra/migrations/1699268680508-DisableActivity.ts b/server/src/infra/migrations/1699268680508-DisableActivity.ts new file mode 100644 index 0000000000..d860244f6d --- /dev/null +++ b/server/src/infra/migrations/1699268680508-DisableActivity.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DisableActivity1699268680508 implements MigrationInterface { + name = 'DisableActivity1699268680508' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" ADD "isActivityEnabled" boolean NOT NULL DEFAULT true`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "isActivityEnabled"`); + } + +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 566514796d..aff498ac34 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -43,6 +43,24 @@ export class AccessRepository implements IAccessRepository { }, }); }, + hasCreateAccess: (userId: string, albumId: string): Promise => { + return this.albumRepository.exist({ + where: [ + { + id: albumId, + isActivityEnabled: true, + sharedUsers: { + id: userId, + }, + }, + { + id: albumId, + isActivityEnabled: true, + ownerId: userId, + }, + ], + }); + }, }; library = { hasOwnerAccess: (userId: string, libraryId: string): Promise => { diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index e10f5414f9..8348eff037 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -226,6 +226,7 @@ describe(`${AlbumController.name} (e2e)`, () => { assets: [], assetCount: 0, owner: expect.objectContaining({ email: user1.userEmail }), + isActivityEnabled: true, }); }); }); diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 48ed928173..fd4464d191 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -18,6 +18,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), sharedWithUser: Object.freeze({ id: 'album-2', @@ -33,6 +34,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [userStub.user1], + isActivityEnabled: true, }), sharedWithMultiple: Object.freeze({ id: 'album-3', @@ -48,6 +50,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [userStub.user1, userStub.user2], + isActivityEnabled: true, }), sharedWithAdmin: Object.freeze({ id: 'album-3', @@ -63,6 +66,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [userStub.admin], + isActivityEnabled: true, }), oneAsset: Object.freeze({ id: 'album-4', @@ -78,6 +82,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), twoAssets: Object.freeze({ id: 'album-4a', @@ -93,6 +98,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', @@ -108,6 +114,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), emptyWithValidThumbnail: Object.freeze({ id: 'album-5', @@ -123,6 +130,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), oneAssetInvalidThumbnail: Object.freeze({ id: 'album-6', @@ -138,6 +146,7 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), oneAssetValidThumbnail: Object.freeze({ id: 'album-6', @@ -153,5 +162,6 @@ export const albumStub = { deletedAt: null, sharedLinks: [], sharedUsers: [], + isActivityEnabled: true, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index dd6eb52334..56a0c10450 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -100,6 +100,7 @@ const albumResponse: AlbumResponseDto = { hasSharedLink: false, assets: [], assetCount: 1, + isActivityEnabled: true, }; export const sharedLinkStub = { @@ -179,6 +180,7 @@ export const sharedLinkStub = { albumThumbnailAssetId: null, sharedUsers: [], sharedLinks: [], + isActivityEnabled: true, assets: [ { id: 'id_1', diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 4f7992e86e..6abfc7c9e1 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -19,6 +19,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => activity: { hasOwnerAccess: jest.fn(), hasAlbumOwnerAccess: jest.fn(), + hasCreateAccess: jest.fn(), }, asset: { hasOwnerAccess: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 9265d439fc..ec6e3f7243 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -331,6 +331,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'id': string; + /** + * + * @type {boolean} + * @memberof AlbumResponseDto + */ + 'isActivityEnabled': boolean; /** * * @type {string} @@ -4160,6 +4166,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'description'?: string; + /** + * + * @type {boolean} + * @memberof UpdateAlbumDto + */ + 'isActivityEnabled'?: boolean; } /** * diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte new file mode 100644 index 0000000000..1a3e43892c --- /dev/null +++ b/web/src/lib/components/album-page/album-options.svelte @@ -0,0 +1,76 @@ + + + dispatch('close')}> +
+
+
+
+

Options

+
+ dispatch('close')} /> +
+
+ +
+
+

SHARING

+
+ dispatch('toggleEnableActivity')} + /> +
+
+
+
PEOPLE
+
+ +
+
+ +
+
{`${user.firstName} ${user.lastName}`}
+
Owner
+
+ {#each album.sharedUsers as user (user.id)} +
+
+ +
+
{`${user.firstName} ${user.lastName}`}
+
+ {/each} +
+
+
+
+
+
+
diff --git a/web/src/lib/components/asset-viewer/activity-status.svelte b/web/src/lib/components/asset-viewer/activity-status.svelte index 23fdb7a9d4..47e29ff974 100644 --- a/web/src/lib/components/asset-viewer/activity-status.svelte +++ b/web/src/lib/components/asset-viewer/activity-status.svelte @@ -7,6 +7,7 @@ export let isLiked: ActivityResponseDto | null; export let numberOfComments: number | undefined; export let isShowActivity: boolean | undefined; + export let disabled: boolean; const dispatch = createEventDispatcher(); @@ -14,7 +15,7 @@
-