diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 9265d439f..ec6e3f724 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 93620b9fc..bc00d30af 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -17,6 +17,7 @@ Name | Type | Description | Notes **endDate** | [**DateTime**](DateTime.md) | | [optional] **hasSharedLink** | **bool** | | **id** | **String** | | +**isActivityEnabled** | **bool** | | **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] **owner** | [**UserResponseDto**](UserResponseDto.md) | | **ownerId** | **String** | | diff --git a/mobile/openapi/doc/UpdateAlbumDto.md b/mobile/openapi/doc/UpdateAlbumDto.md index 283b8bc29..4ded87d1b 100644 --- a/mobile/openapi/doc/UpdateAlbumDto.md +++ b/mobile/openapi/doc/UpdateAlbumDto.md @@ -11,6 +11,7 @@ Name | Type | Description | Notes **albumName** | **String** | | [optional] **albumThumbnailAssetId** | **String** | | [optional] **description** | **String** | | [optional] +**isActivityEnabled** | **bool** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index cf2ad9252..86e009e33 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -22,6 +22,7 @@ class AlbumResponseDto { this.endDate, required this.hasSharedLink, required this.id, + required this.isActivityEnabled, this.lastModifiedAssetTimestamp, required this.owner, required this.ownerId, @@ -55,6 +56,8 @@ class AlbumResponseDto { String id; + bool isActivityEnabled; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -92,6 +95,7 @@ class AlbumResponseDto { other.endDate == endDate && other.hasSharedLink == hasSharedLink && other.id == id && + other.isActivityEnabled == isActivityEnabled && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.owner == owner && other.ownerId == ownerId && @@ -112,6 +116,7 @@ class AlbumResponseDto { (endDate == null ? 0 : endDate!.hashCode) + (hasSharedLink.hashCode) + (id.hashCode) + + (isActivityEnabled.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (owner.hashCode) + (ownerId.hashCode) + @@ -121,7 +126,7 @@ class AlbumResponseDto { (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -142,6 +147,7 @@ class AlbumResponseDto { } json[r'hasSharedLink'] = this.hasSharedLink; json[r'id'] = this.id; + json[r'isActivityEnabled'] = this.isActivityEnabled; if (this.lastModifiedAssetTimestamp != null) { json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); } else { @@ -177,6 +183,7 @@ class AlbumResponseDto { endDate: mapDateTime(json, r'endDate', ''), hasSharedLink: mapValueOfType(json, r'hasSharedLink')!, id: mapValueOfType(json, r'id')!, + isActivityEnabled: mapValueOfType(json, r'isActivityEnabled')!, lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''), owner: UserResponseDto.fromJson(json[r'owner'])!, ownerId: mapValueOfType(json, r'ownerId')!, @@ -239,6 +246,7 @@ class AlbumResponseDto { 'description', 'hasSharedLink', 'id', + 'isActivityEnabled', 'owner', 'ownerId', 'shared', diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index 6c0bf3eca..32d4d2a60 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -16,6 +16,7 @@ class UpdateAlbumDto { this.albumName, this.albumThumbnailAssetId, this.description, + this.isActivityEnabled, }); /// @@ -42,21 +43,31 @@ class UpdateAlbumDto { /// String? description; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isActivityEnabled; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && other.albumName == albumName && other.albumThumbnailAssetId == albumThumbnailAssetId && - other.description == description; + other.description == description && + other.isActivityEnabled == isActivityEnabled; @override int get hashCode => // ignore: unnecessary_parenthesis (albumName == null ? 0 : albumName!.hashCode) + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + - (description == null ? 0 : description!.hashCode); + (description == null ? 0 : description!.hashCode) + + (isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode); @override - String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]'; + String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled]'; Map toJson() { final json = {}; @@ -75,6 +86,11 @@ class UpdateAlbumDto { } else { // json[r'description'] = null; } + if (this.isActivityEnabled != null) { + json[r'isActivityEnabled'] = this.isActivityEnabled; + } else { + // json[r'isActivityEnabled'] = null; + } return json; } @@ -89,6 +105,7 @@ class UpdateAlbumDto { albumName: mapValueOfType(json, r'albumName'), albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), description: mapValueOfType(json, r'description'), + isActivityEnabled: mapValueOfType(json, r'isActivityEnabled'), ); } return null; diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index c84174200..933f77c19 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -61,6 +61,11 @@ void main() { // TODO }); + // bool isActivityEnabled + test('to test the property `isActivityEnabled`', () async { + // TODO + }); + // DateTime lastModifiedAssetTimestamp test('to test the property `lastModifiedAssetTimestamp`', () async { // TODO diff --git a/mobile/openapi/test/update_album_dto_test.dart b/mobile/openapi/test/update_album_dto_test.dart index 7b8472ad3..67ec80010 100644 --- a/mobile/openapi/test/update_album_dto_test.dart +++ b/mobile/openapi/test/update_album_dto_test.dart @@ -31,6 +31,11 @@ void main() { // TODO }); + // bool isActivityEnabled + test('to test the property `isActivityEnabled`', () async { + // TODO + }); + }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 05aec878a..7e4dc7cdb 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 414252778..88abd79b1 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 968f7421a..496d8978b 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 b426bc37d..671922408 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 b8e789943..37d44c33a 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 f574f2c23..3b1858ba1 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 43b53e605..f9ceb6f52 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 38ce4310c..fbc125351 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 000000000..d860244f6 --- /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 566514796..aff498ac3 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 e10f5414f..8348eff03 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 48ed92817..fd4464d19 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 dd6eb5233..56a0c1045 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 4f7992e86..6abfc7c9e 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 9265d439f..ec6e3f724 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 000000000..1a3e43892 --- /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 23fdb7a9d..47e29ff97 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 @@
-