From 61b8eb85b571c547c52960f3dfe4aa70c906c9dc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 16:38:20 -0500 Subject: [PATCH] feat: view album shared links (#15943) --- e2e/src/api/specs/shared-link.e2e-spec.ts | 24 +++++++ mobile/openapi/lib/api/shared_links_api.dart | Bin 14314 -> 14571 bytes open-api/immich-openapi-specs.json | 12 +++- open-api/typescript-sdk/src/fetch-client.ts | 8 ++- .../src/controllers/shared-link.controller.ts | 5 +- server/src/dtos/shared-link.dto.ts | 5 ++ .../src/interfaces/shared-link.interface.ts | 7 +- .../repositories/shared-link.repository.ts | 5 +- .../src/services/shared-link.service.spec.ts | 4 +- server/src/services/shared-link.service.ts | 7 +- .../album-page/album-shared-link.svelte | 40 ++++++++++++ .../album-page/user-selection-modal.svelte | 60 ++++++++---------- .../shared-links/[[id=id]]/+page.svelte | 2 +- 13 files changed, 131 insertions(+), 48 deletions(-) create mode 100644 web/src/lib/components/album-page/album-shared-link.svelte diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 5da6765d2..3918429e4 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -150,6 +150,30 @@ describe('/shared-links', () => { ); }); + it('should filter on albumId', async () => { + const { status, body } = await request(app) + .get(`/shared-links?albumId=${album.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(2); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: linkWithAlbum.id }), + expect.objectContaining({ id: linkWithPassword.id }), + ]), + ); + }); + + it('should find 0 albums', async () => { + const { status, body } = await request(app) + .get(`/shared-links?albumId=${uuidDto.notFound}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(0); + }); + it('should not get shared links created by other users', async () => { const { status, body } = await request(app) .get('/shared-links') diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 12e0224999375d52ced2c48a542537c300530b49..a6b2978fe2adb67d89753115ea43a1c07ac5b3b2 100644 GIT binary patch delta 170 zcmaEr|GIF)NrB1oTndwQnb_GAbCODPJyRxMl#`fzS3p{@S|PZkC^IkJUI8XkC>}Oh zj@x?je)$gW%rpfJn7BWSxgeCSt^-%y&!Ri|faDhrsEpO*Z$egB46WDIm>eoBCkWGL R1!HV}%NE77`Lw9K5&(TbJfQ#p delta 49 zcmV-10M7sGaq4fd$q("/shared-links", { + }>(`/shared-links${QS.query(QS.explode({ + albumId + }))}`, { ...opts })); } diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 59f81068d..ca978f03d 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -9,6 +9,7 @@ import { SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, + SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; import { ImmichCookie, Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; @@ -24,8 +25,8 @@ export class SharedLinkController { @Get() @Authenticated({ permission: Permission.SHARED_LINK_READ }) - getAllSharedLinks(@Auth() auth: AuthDto): Promise { - return this.service.getAll(auth); + getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise { + return this.service.getAll(auth, dto); } @Get('me') diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index b97791db5..e3f8c72e1 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -7,6 +7,11 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +export class SharedLinkSearchDto { + @ValidateUUID({ optional: true }) + albumId?: string; +} + export class SharedLinkCreateDto { @IsEnum(SharedLinkType) @ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' }) diff --git a/server/src/interfaces/shared-link.interface.ts b/server/src/interfaces/shared-link.interface.ts index 25b7237f0..c030ceb73 100644 --- a/server/src/interfaces/shared-link.interface.ts +++ b/server/src/interfaces/shared-link.interface.ts @@ -4,8 +4,13 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity'; export const ISharedLinkRepository = 'ISharedLinkRepository'; +export type SharedLinkSearchOptions = { + userId: string; + albumId?: string; +}; + export interface ISharedLinkRepository { - getAll(userId: string): Promise; + getAll(options: SharedLinkSearchOptions): Promise; get(userId: string, id: string): Promise; getByKey(key: Buffer): Promise; create(entity: Insertable & { assetIds?: string[] }): Promise; diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 3ffae4f06..8e2e6976a 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -7,7 +7,7 @@ import { DB, SharedLinks } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkType } from 'src/enum'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; +import { ISharedLinkRepository, SharedLinkSearchOptions } from 'src/interfaces/shared-link.interface'; @Injectable() export class SharedLinkRepository implements ISharedLinkRepository { @@ -93,7 +93,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getAll(userId: string): Promise { + getAll({ userId, albumId }: SharedLinkSearchOptions): Promise { return this.db .selectFrom('shared_links') .selectAll('shared_links') @@ -149,6 +149,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { ) .select((eb) => eb.fn.toJson('album').as('album')) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) + .$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!)) .orderBy('shared_links.createdAt', 'desc') .distinctOn(['shared_links.createdAt']) .execute() as unknown as Promise; diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 2d673eb7c..0e2901287 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -29,11 +29,11 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); - await expect(sut.getAll(authStub.user1)).resolves.toEqual([ + await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); - expect(sharedLinkMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(sharedLinkMock.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index a015bbe3f..74595bb9a 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -9,6 +9,7 @@ import { SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, + SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { Permission, SharedLinkType } from 'src/enum'; @@ -17,8 +18,10 @@ import { getExternalDomain, OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService extends BaseService { - async getAll(auth: AuthDto): Promise { - return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); + async getAll(auth: AuthDto, { albumId }: SharedLinkSearchDto): Promise { + return this.sharedLinkRepository + .getAll({ userId: auth.user.id, albumId }) + .then((links) => links.map((link) => mapSharedLink(link))); } async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise { diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte new file mode 100644 index 000000000..55c08c4d1 --- /dev/null +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -0,0 +1,40 @@ + + +
+
+ {sharedLink.description || album.albumName} + {[ + DateTime.fromISO(sharedLink.createdAt).toLocaleString( + { + month: 'long', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + ), + sharedLink.allowUpload && $t('upload'), + sharedLink.allowDownload && $t('download'), + sharedLink.showMetadata && $t('exif'), + sharedLink.password && $t('password'), + ] + .filter(Boolean) + .join(' • ')} +
+ +
diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 85155866f..96e3a1672 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -1,4 +1,5 @@ - + {#if Object.keys(selectedUsers).length > 0}
-

{$t('selected').toUpperCase()}

+

{$t('selected')}

{#each Object.values(selectedUsers) as { user }} {#key user.id} @@ -117,7 +113,7 @@
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length} -

{$t('suggestions').toUpperCase()}

+ {$t('users')}
{#each users as user} @@ -144,9 +140,9 @@ {#if users.length > 0}
{/if} -
+
-
- + +
+ {$t('shared_links')} + {$t('view_all')} +
- {#if sharedLinks.length} - - -

{$t('view_links')}

-
- {/if} -
+ + {#each sharedLinks as sharedLink} + + {/each} + + + + diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 436f3b47d..49f9f32b5 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -27,7 +27,7 @@ let sharedLink = $derived(sharedLinks.find(({ id }) => id === page.params.id)); const refresh = async () => { - sharedLinks = await getAllSharedLinks(); + sharedLinks = await getAllSharedLinks({}); }; onMount(async () => {