From b8c5363a15667a55643fca811ed5e193933b9686 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 29 Mar 2024 04:20:40 +0100 Subject: [PATCH] refactor(server): move timeline operations to their own controller/service (#8325) * move timeline operations to their own controller/service * chore: open api * move e2e tests --- e2e/src/api/specs/asset.e2e-spec.ts | 141 ----- e2e/src/api/specs/timeline.e2e-spec.ts | 193 ++++++ mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 25235 -> 25243 bytes mobile/openapi/doc/AssetApi.md | Bin 57994 -> 51664 bytes mobile/openapi/doc/TimelineApi.md | Bin 0 -> 6560 bytes mobile/openapi/lib/api.dart | Bin 8715 -> 8745 bytes mobile/openapi/lib/api/asset_api.dart | Bin 60103 -> 52001 bytes mobile/openapi/lib/api/timeline_api.dart | Bin 0 -> 8519 bytes mobile/openapi/test/asset_api_test.dart | Bin 5791 -> 5141 bytes mobile/openapi/test/timeline_api_test.dart | Bin 0 -> 1111 bytes open-api/immich-openapi-specs.json | 560 +++++++++--------- open-api/typescript-sdk/src/fetch-client.ts | 148 ++--- server/src/app.module.ts | 4 + server/src/controllers/asset.controller.ts | 13 - server/src/controllers/timeline.controller.ts | 26 + server/src/services/asset.service.spec.ts | 125 +--- server/src/services/asset.service.ts | 69 +-- server/src/services/timeline.service.spec.ts | 149 +++++ server/src/services/timeline.service.ts | 86 +++ 20 files changed, 817 insertions(+), 700 deletions(-) create mode 100644 e2e/src/api/specs/timeline.e2e-spec.ts create mode 100644 mobile/openapi/doc/TimelineApi.md create mode 100644 mobile/openapi/lib/api/timeline_api.dart create mode 100644 mobile/openapi/test/timeline_api_test.dart create mode 100644 server/src/controllers/timeline.controller.ts create mode 100644 server/src/services/timeline.service.spec.ts create mode 100644 server/src/services/timeline.service.ts diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index a13bb58eb1..ddc8dd3ef6 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -5,7 +5,6 @@ import { LibraryResponseDto, LoginResponseDto, SharedLinkType, - TimeBucketSize, getAllLibraries, getAssetInfo, updateAssets, @@ -942,146 +941,6 @@ describe('/asset', () => { }); }); - describe('GET /asset/time-buckets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should get time buckets by month', async () => { - const { status, body } = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]), - ); - }); - - it('should not allow access for unrelated shared links', async () => { - const sharedLink = await utils.createSharedLink(user1.accessToken, { - type: SharedLinkType.Individual, - assetIds: user1Assets.map(({ id }) => id), - }); - - const { status, body } = await request(app) - .get('/asset/time-buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.Month }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should get time buckets by day', async () => { - const { status, body } = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Day }); - - expect(status).toBe(200); - expect(body).toEqual([ - { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]); - }); - }); - - describe('GET /asset/time-bucket', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/time-bucket').query({ - size: TimeBucketSize.Month, - timeBucket: '1900-01-01T00:00:00.000Z', - }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should handle 5 digit years', async () => { - const { status, body } = await request(app) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - // TODO enable date string validation while still accepting 5 digit years - // it('should fail if time bucket is invalid', async () => { - // const { status, body } = await request(app) - // .get('/asset/time-bucket') - // .set('Authorization', `Bearer ${user1.accessToken}`) - // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); - - // expect(status).toBe(400); - // expect(body).toEqual(errorDto.badRequest); - // }); - - it('should return time bucket', async () => { - const { status, body } = await request(app) - .get('/asset/time-bucket') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - it('should return error if time bucket is requested with partners asset and archived', async () => { - const req1 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and favorite', async () => { - const req1 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and trash', async () => { - const req = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); - - expect(req.status).toBe(400); - expect(req.body).toEqual(errorDto.badRequest()); - }); - }); - describe('GET /asset', () => { it('should return stack data', async () => { const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`); diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts new file mode 100644 index 0000000000..84daa19f44 --- /dev/null +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -0,0 +1,193 @@ +import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +// TODO this should probably be a test util function +const today = DateTime.fromObject({ + year: 2023, + month: 11, + day: 3, +}) as DateTime; +const yesterday = today.minus({ days: 1 }); + +describe('/timeline', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let timeBucketUser: LoginResponseDto; + + let userAssets: AssetFileUploadResponseDto[]; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + [user, timeBucketUser] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('1')), + utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), + ]); + + userAssets = await Promise.all([ + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken, { + isFavorite: true, + isReadOnly: true, + fileCreatedAt: yesterday.toISO(), + fileModifiedAt: yesterday.toISO(), + assetData: { filename: 'example.mp4' }, + }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + + await Promise.all([ + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + ]); + }); + + describe('GET /timeline/buckets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get time buckets by month', async () => { + const { status, body } = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]), + ); + }); + + it('should not allow access for unrelated shared links', async () => { + const sharedLink = await utils.createSharedLink(user.accessToken, { + type: SharedLinkType.Individual, + assetIds: userAssets.map(({ id }) => id), + }); + + const { status, body } = await request(app) + .get('/timeline/buckets') + .query({ key: sharedLink.key, size: TimeBucketSize.Month }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should get time buckets by day', async () => { + const { status, body } = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Day }); + + expect(status).toBe(200); + expect(body).toEqual([ + { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]); + }); + + it('should return error if time bucket is requested with partners asset and archived', async () => { + const req1 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${user.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and favorite', async () => { + const req1 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and trash', async () => { + const req = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${user.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); + + expect(req.status).toBe(400); + expect(req.body).toEqual(errorDto.badRequest()); + }); + }); + + describe('GET /timeline/bucket', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/timeline/bucket').query({ + size: TimeBucketSize.Month, + timeBucket: '1900-01-01T00:00:00.000Z', + }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should handle 5 digit years', async () => { + const { status, body } = await request(app) + .get('/timeline/bucket') + .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + + // TODO enable date string validation while still accepting 5 digit years + // it('should fail if time bucket is invalid', async () => { + // const { status, body } = await request(app) + // .get('/timeline/bucket') + // .set('Authorization', `Bearer ${user.accessToken}`) + // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest); + // }); + + it('should return time bucket', async () => { + const { status, body } = await request(app) + .get('/timeline/bucket') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + }); +}); diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ddebdaf77d..9ec77670fb 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -169,6 +169,7 @@ doc/TagTypeEnum.md doc/ThumbnailFormat.md doc/TimeBucketResponseDto.md doc/TimeBucketSize.md +doc/TimelineApi.md doc/ToneMapping.md doc/TranscodeHWAccel.md doc/TranscodePolicy.md @@ -211,6 +212,7 @@ lib/api/server_info_api.dart lib/api/shared_link_api.dart lib/api/system_config_api.dart lib/api/tag_api.dart +lib/api/timeline_api.dart lib/api/trash_api.dart lib/api/user_api.dart lib/api_client.dart @@ -556,6 +558,7 @@ test/tag_type_enum_test.dart test/thumbnail_format_test.dart test/time_bucket_response_dto_test.dart test/time_bucket_size_test.dart +test/timeline_api_test.dart test/tone_mapping_test.dart test/transcode_hw_accel_test.dart test/transcode_policy_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index adb7a80fe79a53b3253a7ec9f01a03ba55957b3c..bfdac06c48e69d5b3f70a5172cb6589f055a2134 100644 GIT binary patch delta 117 zcmbPylyUY^#tl`Xo9)GXRM<0fQ*$!&QYT*&6&KXfiq%NTPuABDfr(BPRg@`#a`cl* lle1Gx6lxT>wBQOZiYjuzIp0NDNoB9fC+ ctt2M<3$k+QBcuhy#5Y%o`lxK4Y{j7g0GqcPL;wH) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 1aaf195f3a97c1b6846bfca64196f04cd7c34d12..0778485c37dfa8f6ab8c1f5a25b1afb3e92730d1 100644 GIT binary patch delta 19 bcmeA=%6wrm^M*{;&0K69C7V}Iy6+DFR2~Sl delta 1079 zcmcaGnYrsI^M*`T-t^Rxkj&gvr_$u?)RM^$nT5GZfZQYqH<3kH9>mp!@D*wlxT3YR zP_;Z@7U6(tnf#tnlvf`~NAcz)*8UPSOR^hf)KSdPD9)@()lmTHgSbOSAu%VZG}kj_ zveglp$p%jBIyws3sg*hk`9&$IMLG%vsYS*4d7ddc3Z=!VMIgF7vm_%Rv8W^uD6=`E z*?`3wWTt|xg0+IazCsAxLx)d5JklHUd>5te?!-AoF+5bq2Gt5UYnddF_s58>?S+zY%>LU zRZ9!#EnwuJdR0p+Rzoj0MYBc$JnJ2`)f z_T)$p)yc7o)Ful~Gn;&N8pq^dLlLmNtESrI4Kq0=AI(q&((Icb&HTjZjqIY5Vl6E$ zI|ZN2;u0H#V}eqP3-a@dQ(a2(?G#XzLt~%f7}q2w#>tIU8LGP(6qRThV`I4DWO?3H k0}|pB;vX_2Z}Ov=_Da~}2$XPOGMfV|XESd8Sb5(c0AWv$Qvd(} diff --git a/mobile/openapi/doc/TimelineApi.md b/mobile/openapi/doc/TimelineApi.md new file mode 100644 index 0000000000000000000000000000000000000000..e98efe7e23821710416b65a01749223f178bb5ba GIT binary patch literal 6560 zcmeHLZBN@s5dQ98F%l;wu}A`Kb?QNjltS7f%8MX;s5k_UJt2EIUY~bUpoRZ_&v+f2 zq`ai`uB9g;p~Sn-&d!eKnSF*i(kUlFsv7dwQ3H-u!mU(!-rO90&_P7D&(zvkr!)vp zgA?|SkB_4uvz{8HI>c?230pbMn_+m$*7pyqV`pm& zqB+c4%y;wo8Z7w!yLQL-sVTz}gr=Lr)K~V*eFn{47LAxhe6i(}m);6}P{zJ$8N+s$ zO{x>aYgVt&1h8tztVi>tOe+ifh;aVp?du^Lm zb<$HOLnMM^6da*oh?k$K&&Ud$EK#WSsba6$jJOvJV~b2&jTI-hfe5sK#ZMLH;8&R2 z;0qaCsaD*=N=@<@qx708AGh~+kFd|tM*Aa@5k3r)P!9$$L0yjZnIs_sJxnRhxTnsk z%8io-yNIMs6a0JxZ7(j=tZw9k&@gbUQiUW{VF_cZ9G#z=>kzb2Eh~ zFBdL^(`nSrU!lD6)xa<QS79HV2FC&`wGa+9#M{Ed3q8zoyYC!C zXQQRn8hxQgqd`~y@&Rt&66f%GDhXnex1wZHLCAZNVB`#5=Df^t zh)&7NiO5WOJ7Ve#)*T^d0FLvnfZrajG5Xz&%_ZC}R8N+WMT&W%Zf{nda^PtxDyiXQ zpxlVp$_-YW0z>jNn!D}R+Gbm1wi~R?(?M~?Pt6yj0&rLB)Vv!kcAV%B`W&EEf-Y1? zl0sNo=(@U)#`l+mCIkry86P=-T-GTXfhE+NG%h4$^Dl^BxPI^k3Rl#i)#>43b*@I` zkOr?BH_Z*K?7*Dk|GwI@6TC0L;v+2gxcyCp#e4G)Qc5bE+J+%(^ zx7RcGK0mT`cxnS4*!=v$ZNQJbsQzQ^Z+i|zi2Jf;+%M1Ik} I{B)8355w6ghABY5t diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index b0395bfcbedb7514f91e07ef42543c430d2115fe..e16ccc73e55a912efcb02b6cee636e7f292c27ae 100644 GIT binary patch delta 18 acmX?pm3iSb<_+_*Cv#ur*&NVa9uELivIuqn delta 1631 zcmZ2DjrsUh<_+_*Cok~f<|@g|P1Q{*P0mi8ym5nW zCKnWNfYm}IVM1()IZ36to|6S5r6xDXT1_?(<6sLZN-WMuoorYrHTl3ck;xbOwb%+$ zi;DB}CL0t=Prf6@HCZfDc=Ci&mdSpRJd=O$@J^mENoMj}G2Q@#<>8qn86G7i1)h0n z`5Fjekek3x(oqO5Day=CR{%L5;v^k~YN(*S0>o7*lP4b2o7`{C$qi9#uaKQuIeA~X ztOUe8o+&V6>=jCjQ;R%PCL4r^P5wPqX|j!>JtxR$aL7zHSZh1^LuC&u*x1R6(v|{H z3)FR>R!uhS5}uq8c4cz6W&C8DE{@4F9DOHyHz&h<4%8z9vlOTa#?;ib=28HH+Q}a! zbtW^n2~YkRuP%k~6HqCF15`X&L>go~&t!}6l*uc*cs4h87cdS~oSC|(r?r=tK%6jm^X&g4JV?pDAgVFgMTItuwkDXB$P5DF1RR`AHufhH0w zSYp``CAaz4OkrjocuGkvDW3e%&J%BpP&LcgA;P~H#s3I#q&!3248o1%?aA-WPtYTK zNJ>YMmj0;~+KPUN5DiK#F38U-PIW2Cx3jlXKuLtf*s}rELf+CC)jE_&ha`Gf`lfb5 fFJj_Cgu!ISATos~k7`_{ye*a?hdh}*E9v#8)$*WNZj!xnA=@@=K zJvur5^&U>6nLlMr+k;2lA09QRD=wwdd^%TpIu|+b!%D6U&!#-*OD=6E_QkSL+SW?2 z{F<%Jv{>1x=ATwVqqt;C{4SWrU(2;Y<=WJX&y+NlNjnu88WeNEwG%hB#&RLKndVAf z;xxNpa{A-L>1@HYX*AHB1vLj%aLEeM#m|#QqcO{wF)$WOPQt@Nz?TM8`>k^ve4Yz5 zKL8Z+%}E>jsRsv@_Fw=RpR-kNOF=XQFc*^LP>v-D_q2!ZKI0l)y)?iiyd00up!s4n zhMos_p7AGvMP7BsU$Nt~F0=60o}0lVOE{)U?ZUB)L}wCHq}UheymF#a8!)25#} zm2buGd;$i)p%qW5y*2->)k0oQfY#&X?lPO@7pvt-wx#VtseA&$4E1awuK4W}j@gyc z!tz@+j5RZh8U=B^ANhJqc4!Q@r#j<$0*ZdNW<+W9wNbKW191XIgsyq)_jfJq;tX$| zL@iq;(v-KB;n6xD9Ix;=`O|oihj7VlI;r=l@1?cHiJYre8m0qu=hp$htjZyE;7b_9 z`|A&*17vzo@qQYK?oK1Yzftu7(4iej9vpZNrsvURX~g>|s;oMtIu^Xpftxlkvz9X# zi*%1eCh9|>`5a^0rG;t6F?xL@7`)ao#0q8?7?HZU6*(@_>h!tT#KbXUxz}`h@f)9E zz!%EcXDV9>i#^o>UgH;0$k8n&^)vdgbuTO?5Iw{#U0u z#bOSvaENg40OTsq+rdi!m34JlmJRc~HLZ-bn#~S0gETfntKIg?xG4>!pxn_$Qh;Vs zr_NHmZaZ6w3Y?|bsk0QW@y>>Vyt5P9+h#B{b~J#Js2abwmAVAAqgp46irw$F>KLj! zYLDV*AT~E`-0@OU=|LUz6XowHnTiq0r%u#D@#2%ujyv?isM|N9-V`j=WVWM?stUAJ z?bh21iSirF1r5Pk=p|z=6}Th|_-zsUjMoKs+0G|1m+1)AK)@df&UA&mQXl#7?4;$M z15iTlgf>B%X6#gwQyhv3H})ZI8Aqy%>LW zKtu+}a3WqzMZ8}r=v3Te$))kwrtwfBX*e`pb?bZd=rMN$jcmN#q*269zYq3DbcOMDY8V z5Ye3dfCf;F-y(9(`|l+GTp;e{8E|BlC@my1_xn-xF=A9^OHaTxZNloc)34%MP!o3v z*f_%I7q)Q%v^h-Eua)(g`oRO-x+kvH(?Nh+IUT8<`%;t+XY;O;*US5U`$>4TTh^_X zKe>|GcQcNsAy|%KF(eXFo+0T+09~JNnnYQEp0z`z1L)3JU21AoW>`fctxLRQ?1J8~ z%#nd`ytPh=M;V^ryJALbPX+5vuM5mS{=Oh2k*&(Ie*3@i?F>q5)!T;-EWs`%H{A1%^n@!B-33ueydy)--f$mAR$q delta 395 zcmbQLF<*Cs1kdC-EHX|ZnYpP>rODZ;B^n53aAsAiLNSogQ3x(6%FIhwD1j@}QK*Iq zCgvoS=6X&};FX^IfrV@G19rYZxQ6W1N*x8q;^Nd2|Du%CB87Yq370KMEh^5>^GtyY zloqEJfw)Qe`8f*ZnI#zkiA5!OKvk2s@oV#>r2VN9cO-|%7+AP8ImmL6F+m9ar diff --git a/mobile/openapi/test/timeline_api_test.dart b/mobile/openapi/test/timeline_api_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..ae217b2e406254979963a8421dbd3ad8d5af7bfc GIT binary patch literal 1111 zcmds#OOKl{5PndM3;6?)xOkJBvqvCQf=s=C**|*83k-=4_T$F{P&ItN%}xl z&plwkeDm-(L{Sh$F#VDyuRqQnW{=Z!7Q=jTKMNsF;2}xjQxY%cUtbC4$@hh_e0zEE z_A>CVRBB@}TN#_JsKP5~b?dOmu)-QOhsSrS8)JDeLRHUd=dz~d%;L9}tb}V-%i1U_ zcSh?Zxpl+gxzUc5=8Os{NGn3yg}cFG6=`%?WppVRua(Z;y~*;mvMvY?J;JC_`9_s^ zWg66~p-8R^f9lco9|)u4$S+60ONqemI1nk2QjNamw4q>YsDN6L9#7yU01$9RT2(;Z z337znpZW3RIuNR~rftTf!IYyA`dmKSyye#7%{@83nL3C3h^`Sk7_+&%gA&qQa5lQGh=o7#L$TRd;f)z(;INz)UlRc`Hp zKJ5l~A#{x;wm=KUucxn#jM->kpvUDuvZs2_#Ou#811(fvz;7*CW`zapC!pL;=i&=G u`v+map7~&_<2wJIl^LW-oOE&fgYe%~o=yG5ryCafAK7#NsLxy62R{HoRd`td literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3bd92a9d95..0bd6524db4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1720,286 +1720,6 @@ ] } }, - "/asset/time-bucket": { - "get": { - "operationId": "getTimeBucket", - "parameters": [ - { - "name": "albumId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isTrashed", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/AssetOrder" - } - }, - { - "name": "personId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, - { - "name": "timeBucket", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "userId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "withPartners", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withStacked", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - }, - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, - "/asset/time-buckets": { - "get": { - "operationId": "getTimeBuckets", - "parameters": [ - { - "name": "albumId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isTrashed", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/AssetOrder" - } - }, - { - "name": "personId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, - { - "name": "userId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "withPartners", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withStacked", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/TimeBucketResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - }, - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, "/asset/upload": { "post": { "operationId": "uploadFile", @@ -6048,6 +5768,286 @@ ] } }, + "/timeline/bucket": { + "get": { + "operationId": "getTimeBucket", + "parameters": [ + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "personId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "size", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/TimeBucketSize" + } + }, + { + "name": "timeBucket", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "withPartners", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Timeline" + ] + } + }, + "/timeline/buckets": { + "get": { + "operationId": "getTimeBuckets", + "parameters": [ + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "personId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "size", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/TimeBucketSize" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "withPartners", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TimeBucketResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Timeline" + ] + } + }, "/trash/empty": { "post": { "operationId": "emptyTrash", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 4164a3d1c9..00794f1208 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -284,10 +284,6 @@ export type AssetStatsResponseDto = { total: number; videos: number; }; -export type TimeBucketResponseDto = { - count: number; - timeBucket: string; -}; export type CreateAssetDto = { assetData: Blob; deviceAssetId: string; @@ -971,6 +967,10 @@ export type CreateTagDto = { export type UpdateTagDto = { name?: string; }; +export type TimeBucketResponseDto = { + count: number; + timeBucket: string; +}; export type CreateUserDto = { email: string; memoriesEnabled?: boolean; @@ -1456,72 +1456,6 @@ export function getAssetThumbnail({ format, id, key }: { ...opts })); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { - albumId?: string; - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - key?: string; - order?: AssetOrder; - personId?: string; - size: TimeBucketSize; - timeBucket: string; - userId?: string; - withPartners?: boolean; - withStacked?: boolean; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetResponseDto[]; - }>(`/asset/time-bucket${QS.query(QS.explode({ - albumId, - isArchived, - isFavorite, - isTrashed, - key, - order, - personId, - size, - timeBucket, - userId, - withPartners, - withStacked - }))}`, { - ...opts - })); -} -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { - albumId?: string; - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - key?: string; - order?: AssetOrder; - personId?: string; - size: TimeBucketSize; - userId?: string; - withPartners?: boolean; - withStacked?: boolean; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: TimeBucketResponseDto[]; - }>(`/asset/time-buckets${QS.query(QS.explode({ - albumId, - isArchived, - isFavorite, - isTrashed, - key, - order, - personId, - size, - userId, - withPartners, - withStacked - }))}`, { - ...opts - })); -} export function uploadFile({ key, createAssetDto }: { key?: string; createAssetDto: CreateAssetDto; @@ -2595,6 +2529,72 @@ export function tagAssets({ id, assetIdsDto }: { body: assetIdsDto }))); } +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { + albumId?: string; + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + key?: string; + order?: AssetOrder; + personId?: string; + size: TimeBucketSize; + timeBucket: string; + userId?: string; + withPartners?: boolean; + withStacked?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/timeline/bucket${QS.query(QS.explode({ + albumId, + isArchived, + isFavorite, + isTrashed, + key, + order, + personId, + size, + timeBucket, + userId, + withPartners, + withStacked + }))}`, { + ...opts + })); +} +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { + albumId?: string; + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + key?: string; + order?: AssetOrder; + personId?: string; + size: TimeBucketSize; + userId?: string; + withPartners?: boolean; + withStacked?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TimeBucketResponseDto[]; + }>(`/timeline/buckets${QS.query(QS.explode({ + albumId, + isArchived, + isFavorite, + isTrashed, + key, + order, + personId, + size, + userId, + withPartners, + withStacked + }))}`, { + ...opts + })); +} export function emptyTrash(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/trash/empty", { ...opts, @@ -2789,10 +2789,6 @@ export enum ThumbnailFormat { Jpeg = "JPEG", Webp = "WEBP" } -export enum TimeBucketSize { - Day = "DAY", - Month = "MONTH" -} export enum EntityType { Asset = "ASSET", Album = "ALBUM" @@ -2911,3 +2907,7 @@ export enum MapTheme { Light = "light", Dark = "dark" } +export enum TimeBucketSize { + Day = "DAY", + Month = "MONTH" +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index aedd6adf1f..f7aa5f5949 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -31,6 +31,7 @@ import { ServerInfoController } from 'src/controllers/server-info.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; import { TagController } from 'src/controllers/tag.controller'; +import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; import { UserController } from 'src/controllers/user.controller'; import { databaseConfig } from 'src/database.config'; @@ -121,6 +122,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SystemConfigService } from 'src/services/system-config.service'; import { TagService } from 'src/services/tag.service'; +import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; import { UserService } from 'src/services/user.service'; import { otelConfig } from 'src/utils/instrumentation'; @@ -157,6 +159,7 @@ const controllers = [ SharedLinkController, SystemConfigController, TagController, + TimelineController, TrashController, UserController, PersonController, @@ -189,6 +192,7 @@ const services: Provider[] = [ StorageTemplateService, SystemConfigService, TagService, + TimelineService, TrashService, UserService, ]; diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 37e1691137..8e446d23f9 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -14,7 +14,6 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, MetadataSearchDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; @@ -71,18 +70,6 @@ export class AssetController { return this.service.getStatistics(auth, dto); } - @Authenticated({ isShared: true }) - @Get('time-buckets') - getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { - return this.service.getTimeBuckets(auth, dto); - } - - @Authenticated({ isShared: true }) - @Get('time-bucket') - getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { - return this.service.getTimeBucket(auth, dto) as Promise; - } - @Post('jobs') @HttpCode(HttpStatus.NO_CONTENT) runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise { diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts new file mode 100644 index 0000000000..173c6738de --- /dev/null +++ b/server/src/controllers/timeline.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { TimelineService } from 'src/services/timeline.service'; + +@ApiTags('Timeline') +@Controller('timeline') +@Authenticated() +export class TimelineController { + constructor(private service: TimelineService) {} + + @Authenticated({ isShared: true }) + @Get('buckets') + getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { + return this.service.getTimeBuckets(auth, dto); + } + + @Authenticated({ isShared: true }) + @Get('bucket') + getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { + return this.service.getTimeBucket(auth, dto) as Promise; + } +} diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index d5bce3d1ec..f7e502e693 100644 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,7 +4,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { AssetStats, IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobItem, JobName } from 'src/interfaces/job.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -335,129 +335,6 @@ describe(AssetService.name, () => { }); }); - describe('getTimeBuckets', () => { - it("should return buckets if userId and albumId aren't set", async () => { - assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); - - await expect( - sut.getTimeBuckets(authStub.admin, { - size: TimeBucketSize.DAY, - }), - ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); - expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id] }); - }); - }); - - describe('getTimeBucket', () => { - it('should return the assets for a album time bucket if user has album.read', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - albumId: 'album-id', - }); - }); - - it('should return the assets for a archive time bucket if user has archive.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - userId: authStub.admin.user.id, - }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - userIds: [authStub.admin.user.id], - }); - }); - - it('should return the assets for a library time bucket if user has library.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - userId: authStub.admin.user.id, - }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - userIds: [authStub.admin.user.id], - }); - }); - - it('should throw an error if withParners is true and isArchived true or undefined', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: undefined, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - - it('should throw an error if withParners is true and isFavorite is either true or false', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isFavorite: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isFavorite: false, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - - it('should throw an error if withParners is true and isTrash is true', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isTrashed: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - }); - describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { assetMock.getStatistics.mockResolvedValue(stats); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 17fe147c01..b8a9dec874 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -25,12 +25,11 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryType } from 'src/entities/library.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IAssetDeletionJob, @@ -195,72 +194,6 @@ export class AssetService { })); } - private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { - if (dto.albumId) { - await this.access.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); - } else { - dto.userId = dto.userId || auth.user.id; - } - - if (dto.userId) { - await this.access.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); - if (dto.isArchived !== false) { - await this.access.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); - } - } - - if (dto.withPartners) { - const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; - const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; - const requestedTrash = dto.isTrashed === true; - - if (requestedArchived || requestedFavorite || requestedTrash) { - throw new BadRequestException( - 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', - ); - } - } - } - - async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { - await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - - return this.assetRepository.getTimeBuckets(timeBucketOptions); - } - - async getTimeBucket( - auth: AuthDto, - dto: TimeBucketAssetDto, - ): Promise { - await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); - return !auth.sharedLink || auth.sharedLink?.showExif - ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) - : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); - } - - async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { - const { userId, ...options } = dto; - let userIds: string[] | undefined = undefined; - - if (userId) { - userIds = [userId]; - - if (dto.withPartners) { - const partners = await this.partnerRepository.getAll(auth.user.id); - const partnersIds = partners - .filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline) - .map((partner) => partner.sharedById); - - userIds.push(...partnersIds); - } - } - - return { ...options, userIds }; - } - async getStatistics(auth: AuthDto, dto: AssetStatsDto) { const stats = await this.assetRepository.getStatistics(auth.user.id, dto); return mapStats(stats); diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts new file mode 100644 index 0000000000..c6f058022f --- /dev/null +++ b/server/src/services/timeline.service.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException } from '@nestjs/common'; +import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { TimelineService } from 'src/services/timeline.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; + +describe(TimelineService.name, () => { + let sut: TimelineService; + let accessMock: IAccessRepositoryMock; + let assetMock: jest.Mocked; + let partnerMock: jest.Mocked; + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + assetMock = newAssetRepositoryMock(); + partnerMock = newPartnerRepositoryMock(); + + sut = new TimelineService(accessMock, assetMock, partnerMock); + }); + + describe('getTimeBuckets', () => { + it("should return buckets if userId and albumId aren't set", async () => { + assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + + await expect( + sut.getTimeBuckets(authStub.admin, { + size: TimeBucketSize.DAY, + }), + ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); + expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({ + size: TimeBucketSize.DAY, + userIds: [authStub.admin.user.id], + }); + }); + }); + + describe('getTimeBucket', () => { + it('should return the assets for a album time bucket if user has album.read', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + albumId: 'album-id', + }); + }); + + it('should return the assets for a archive time bucket if user has archive.read', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userId: authStub.admin.user.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userIds: [authStub.admin.user.id], + }); + }); + + it('should return the assets for a library time bucket if user has library.read', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userId: authStub.admin.user.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }); + }); + + it('should throw an error if withParners is true and isArchived true or undefined', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: undefined, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw an error if withParners is true and isFavorite is either true or false', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isFavorite: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isFavorite: false, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw an error if withParners is true and isTrash is true', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isTrashed: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts new file mode 100644 index 0000000000..95c4081e6a --- /dev/null +++ b/server/src/services/timeline.service.ts @@ -0,0 +1,86 @@ +import { BadRequestException, Inject } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; + +export class TimelineService { + private accessCore: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private repository: IAssetRepository, + @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, + ) { + this.accessCore = AccessCore.create(accessRepository); + } + + async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { + await this.timeBucketChecks(auth, dto); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); + + return this.repository.getTimeBuckets(timeBucketOptions); + } + + async getTimeBucket( + auth: AuthDto, + dto: TimeBucketAssetDto, + ): Promise { + await this.timeBucketChecks(auth, dto); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); + const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions); + return !auth.sharedLink || auth.sharedLink?.showExif + ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) + : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); + } + + private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { + const { userId, ...options } = dto; + let userIds: string[] | undefined = undefined; + + if (userId) { + userIds = [userId]; + + if (dto.withPartners) { + const partners = await this.partnerRepository.getAll(auth.user.id); + const partnersIds = partners + .filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline) + .map((partner) => partner.sharedById); + + userIds.push(...partnersIds); + } + } + + return { ...options, userIds }; + } + + private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { + if (dto.albumId) { + await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); + } else { + dto.userId = dto.userId || auth.user.id; + } + + if (dto.userId) { + await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); + if (dto.isArchived !== false) { + await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); + } + } + + if (dto.withPartners) { + const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; + const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; + const requestedTrash = dto.isTrashed === true; + + if (requestedArchived || requestedFavorite || requestedTrash) { + throw new BadRequestException( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + } + } + } +}