From cd0e537e3ea44db4d081bbdeb1dfcff8fcf8cd8a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 2 Apr 2024 10:23:17 -0400 Subject: [PATCH] feat: persistent memories (#8330) * feat: persistent memories * refactor: use new add/remove asset utility --- e2e/src/api/specs/memory.e2e-spec.ts | 376 ++++++++++++++++ mobile/openapi/.openapi-generator/FILES | 15 + mobile/openapi/README.md | Bin 25273 -> 26129 bytes mobile/openapi/doc/MemoryApi.md | Bin 0 -> 14606 bytes mobile/openapi/doc/MemoryCreateDto.md | Bin 0 -> 682 bytes mobile/openapi/doc/MemoryResponseDto.md | Bin 0 -> 890 bytes mobile/openapi/doc/MemoryType.md | Bin 0 -> 376 bytes mobile/openapi/doc/MemoryUpdateDto.md | Bin 0 -> 539 bytes mobile/openapi/lib/api.dart | Bin 8773 -> 8945 bytes mobile/openapi/lib/api/memory_api.dart | Bin 0 -> 11601 bytes mobile/openapi/lib/api_client.dart | Bin 24512 -> 24849 bytes mobile/openapi/lib/api_helper.dart | Bin 6142 -> 6240 bytes .../openapi/lib/model/memory_create_dto.dart | Bin 0 -> 4909 bytes .../lib/model/memory_response_dto.dart | Bin 0 -> 8649 bytes mobile/openapi/lib/model/memory_type.dart | Bin 0 -> 2461 bytes .../openapi/lib/model/memory_update_dto.dart | Bin 0 -> 4591 bytes mobile/openapi/test/memory_api_test.dart | Bin 0 -> 1444 bytes .../openapi/test/memory_create_dto_test.dart | Bin 0 -> 1090 bytes .../test/memory_response_dto_test.dart | Bin 0 -> 1601 bytes mobile/openapi/test/memory_type_test.dart | Bin 0 -> 417 bytes .../openapi/test/memory_update_dto_test.dart | Bin 0 -> 767 bytes open-api/immich-openapi-specs.json | 424 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 109 +++++ server/src/controllers/index.ts | 2 + server/src/controllers/memory.controller.ts | 64 +++ server/src/cores/access.core.ts | 16 + server/src/dtos/memory.dto.ts | 84 ++++ server/src/entities/index.ts | 2 + server/src/entities/memory.entity.ts | 67 +++ server/src/interfaces/access.interface.ts | 4 + server/src/interfaces/memory.interface.ts | 14 + .../1711637874206-AddMemoryTable.ts | 26 ++ server/src/queries/access.repository.sql | 14 + server/src/queries/memory.repository.sql | 18 + server/src/repositories/access.repository.ts | 27 ++ server/src/repositories/index.ts | 3 + server/src/repositories/memory.repository.ts | 104 +++++ server/src/services/index.ts | 2 + server/src/services/memory.service.spec.ts | 214 +++++++++ server/src/services/memory.service.ts | 105 +++++ server/test/fixtures/memory.stub.ts | 30 ++ .../repositories/access.repository.mock.ts | 5 + .../repositories/memory.repository.mock.ts | 14 + 43 files changed, 1739 insertions(+) create mode 100644 e2e/src/api/specs/memory.e2e-spec.ts create mode 100644 mobile/openapi/doc/MemoryApi.md create mode 100644 mobile/openapi/doc/MemoryCreateDto.md create mode 100644 mobile/openapi/doc/MemoryResponseDto.md create mode 100644 mobile/openapi/doc/MemoryType.md create mode 100644 mobile/openapi/doc/MemoryUpdateDto.md create mode 100644 mobile/openapi/lib/api/memory_api.dart create mode 100644 mobile/openapi/lib/model/memory_create_dto.dart create mode 100644 mobile/openapi/lib/model/memory_response_dto.dart create mode 100644 mobile/openapi/lib/model/memory_type.dart create mode 100644 mobile/openapi/lib/model/memory_update_dto.dart create mode 100644 mobile/openapi/test/memory_api_test.dart create mode 100644 mobile/openapi/test/memory_create_dto_test.dart create mode 100644 mobile/openapi/test/memory_response_dto_test.dart create mode 100644 mobile/openapi/test/memory_type_test.dart create mode 100644 mobile/openapi/test/memory_update_dto_test.dart create mode 100644 server/src/controllers/memory.controller.ts create mode 100644 server/src/dtos/memory.dto.ts create mode 100644 server/src/entities/memory.entity.ts create mode 100644 server/src/interfaces/memory.interface.ts create mode 100644 server/src/migrations/1711637874206-AddMemoryTable.ts create mode 100644 server/src/queries/memory.repository.sql create mode 100644 server/src/repositories/memory.repository.ts create mode 100644 server/src/services/memory.service.spec.ts create mode 100644 server/src/services/memory.service.ts create mode 100644 server/test/fixtures/memory.stub.ts create mode 100644 server/test/repositories/memory.repository.mock.ts diff --git a/e2e/src/api/specs/memory.e2e-spec.ts b/e2e/src/api/specs/memory.e2e-spec.ts new file mode 100644 index 0000000000..35af1fea9e --- /dev/null +++ b/e2e/src/api/specs/memory.e2e-spec.ts @@ -0,0 +1,376 @@ +import { + AssetFileUploadResponseDto, + LoginResponseDto, + MemoryResponseDto, + MemoryType, + createMemory, + getMemory, +} from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/memories', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let adminAsset: AssetFileUploadResponseDto; + let userAsset1: AssetFileUploadResponseDto; + let userAsset2: AssetFileUploadResponseDto; + let userMemory: MemoryResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, createUserDto.user1); + [adminAsset, userAsset1, userAsset2] = await Promise.all([ + utils.createAsset(admin.accessToken), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + userMemory = await createMemory( + { + memoryCreateDto: { + type: MemoryType.OnThisDay, + memoryAt: new Date(2021).toISOString(), + data: { year: 2021 }, + assetIds: [], + }, + }, + { headers: asBearerAuth(user.accessToken) }, + ); + }); + + describe('GET /memories', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/memories'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('POST /memories', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/memories'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should validate data when type is on this day', async () => { + const { status, body } = await request(app) + .post('/memories') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ + type: 'on_this_day', + data: {}, + memoryAt: new Date(2021).toISOString(), + }); + + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']), + ); + }); + + it('should create a new memory', async () => { + const { status, body } = await request(app) + .post('/memories') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ + type: 'on_this_day', + data: { year: 2021 }, + memoryAt: new Date(2021).toISOString(), + }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + type: 'on_this_day', + data: { year: 2021 }, + createdAt: expect.any(String), + updatedAt: expect.any(String), + deletedAt: null, + seenAt: null, + isSaved: false, + memoryAt: expect.any(String), + ownerId: user.userId, + assets: [], + }); + }); + + it('should create a new memory (with assets)', async () => { + const { status, body } = await request(app) + .post('/memories') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ + type: 'on_this_day', + data: { year: 2021 }, + memoryAt: new Date(2021).toISOString(), + assetIds: [userAsset1.id, userAsset2.id], + }); + + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + assets: expect.arrayContaining([ + expect.objectContaining({ id: userAsset1.id }), + expect.objectContaining({ id: userAsset2.id }), + ]), + }); + expect(body.assets).toHaveLength(2); + }); + + it('should create a new memory and ignore assets the user does not have access to', async () => { + const { status, body } = await request(app) + .post('/memories') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ + type: 'on_this_day', + data: { year: 2021 }, + memoryAt: new Date(2021).toISOString(), + assetIds: [userAsset1.id, adminAsset.id], + }); + + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + assets: [expect.objectContaining({ id: userAsset1.id })], + }); + expect(body.assets).toHaveLength(1); + }); + }); + + describe('GET /memories/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/memories/${uuidDto.invalid}`); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .get(`/memories/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .get(`/memories/${userMemory.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should get the memory', async () => { + const { status, body } = await request(app) + .get(`/memories/${userMemory.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ id: userMemory.id }); + }); + }); + + describe('PUT /memories/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/memories/${uuidDto.invalid}`).send({ isSaved: true }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .put(`/memories/${uuidDto.invalid}`) + .send({ isSaved: true }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .put(`/memories/${userMemory.id}`) + .send({ isSaved: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should update the memory', async () => { + const before = await getMemory({ id: userMemory.id }, { headers: asBearerAuth(user.accessToken) }); + expect(before.isSaved).toBe(false); + + const { status, body } = await request(app) + .put(`/memories/${userMemory.id}`) + .send({ isSaved: true }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: userMemory.id, + isSaved: true, + }); + }); + }); + + describe('PUT /memories/:id/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .put(`/memories/${userMemory.id}/assets`) + .send({ ids: [userAsset1.id] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .put(`/memories/${uuidDto.invalid}/assets`) + .send({ ids: [userAsset1.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .put(`/memories/${userMemory.id}/assets`) + .send({ ids: [userAsset1.id] }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require a valid asset id', async () => { + const { status, body } = await request(app) + .put(`/memories/${userMemory.id}/assets`) + .send({ ids: [uuidDto.invalid] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID'])); + }); + + it('should require asset access', async () => { + const { status, body } = await request(app) + .put(`/memories/${userMemory.id}/assets`) + .send({ ids: [adminAsset.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body[0]).toEqual({ + id: adminAsset.id, + success: false, + error: 'no_permission', + }); + }); + + it('should add assets to the memory', async () => { + const { status, body } = await request(app) + .put(`/memories/${userMemory.id}/assets`) + .send({ ids: [userAsset1.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body[0]).toEqual({ id: userAsset1.id, success: true }); + }); + }); + + describe('DELETE /memories/:id/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .delete(`/memories/${userMemory.id}/assets`) + .send({ ids: [userAsset1.id] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .delete(`/memories/${uuidDto.invalid}/assets`) + .send({ ids: [userAsset1.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .delete(`/memories/${userMemory.id}/assets`) + .send({ ids: [userAsset1.id] }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require a valid asset id', async () => { + const { status, body } = await request(app) + .delete(`/memories/${userMemory.id}/assets`) + .send({ ids: [uuidDto.invalid] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID'])); + }); + + it('should only remove assets in the memory', async () => { + const { status, body } = await request(app) + .delete(`/memories/${userMemory.id}/assets`) + .send({ ids: [adminAsset.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body[0]).toEqual({ + id: adminAsset.id, + success: false, + error: 'not_found', + }); + }); + + it('should remove assets from the memory', async () => { + const { status, body } = await request(app) + .delete(`/memories/${userMemory.id}/assets`) + .send({ ids: [userAsset1.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body[0]).toEqual({ id: userAsset1.id, success: true }); + }); + }); + + describe('DELETE /memories/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/memories/${uuidDto.invalid}`); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .delete(`/memories/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .delete(`/memories/${userMemory.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should delete the memory', async () => { + const { status } = await request(app) + .delete(`/memories/${userMemory.id}`) + .send({ isSaved: true }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + }); + }); +}); diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 795943e299..4e109c14db 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -90,7 +90,12 @@ doc/LoginResponseDto.md doc/LogoutResponseDto.md doc/MapMarkerResponseDto.md doc/MapTheme.md +doc/MemoryApi.md +doc/MemoryCreateDto.md doc/MemoryLaneResponseDto.md +doc/MemoryResponseDto.md +doc/MemoryType.md +doc/MemoryUpdateDto.md doc/MergePersonDto.md doc/MetadataSearchDto.md doc/ModelType.md @@ -205,6 +210,7 @@ lib/api/download_api.dart lib/api/face_api.dart lib/api/job_api.dart lib/api/library_api.dart +lib/api/memory_api.dart lib/api/o_auth_api.dart lib/api/partner_api.dart lib/api/person_api.dart @@ -301,7 +307,11 @@ lib/model/login_response_dto.dart lib/model/logout_response_dto.dart lib/model/map_marker_response_dto.dart lib/model/map_theme.dart +lib/model/memory_create_dto.dart lib/model/memory_lane_response_dto.dart +lib/model/memory_response_dto.dart +lib/model/memory_type.dart +lib/model/memory_update_dto.dart lib/model/merge_person_dto.dart lib/model/metadata_search_dto.dart lib/model/model_type.dart @@ -481,7 +491,12 @@ test/login_response_dto_test.dart test/logout_response_dto_test.dart test/map_marker_response_dto_test.dart test/map_theme_test.dart +test/memory_api_test.dart +test/memory_create_dto_test.dart test/memory_lane_response_dto_test.dart +test/memory_response_dto_test.dart +test/memory_type_test.dart +test/memory_update_dto_test.dart test/merge_person_dto_test.dart test/metadata_search_dto_test.dart test/model_type_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a46f2383e5d048639937c9158663bcedcc3aa812..fede2901c7728ddf695fe8e526a739c2f97d28eb 100644 GIT binary patch delta 826 zcmdmalyTx2#tl-+^}eaO`9+nE1({k3H44#MT8SwsP@&@D)RJN?tyqne{A7K7xH`Su z6lI{IT(G`Gup&*MRxPc7&=4&x1%0qYW@@p1b!JMfK12afo=XdE0@SADqSVBa`c#N8 zc6-1|QXy7BE%6UVu?4HiDXBTBC3p>nr~n#V5A;05WEWQ-*AQ0>A7Hg0J+%a%vw_mc zZgzJ?aWXhyV8(*%M+ANmFi^{&4tK;8{t$H#JCLIQhad37LcLaTYGP4x2G~2mSi>F< z5H(=?fNCJ&h2|@mhfw@eT9A^6KO)p1N|1vKTRNJ2QCVcOrn0QIyfY}(q`H*kqohl? z=wx$0S7u;}o_x?=m=)})$@vbVu3%PWNM%7PiZ+k{FgDd;LhL)3(Vos4ciSafr) HpN|LtE$AF# delta 23 fcmbPuhH>Xn#tl-+n-8e!Xit_3G~0Y7&{hNhb=wIS diff --git a/mobile/openapi/doc/MemoryApi.md b/mobile/openapi/doc/MemoryApi.md new file mode 100644 index 0000000000000000000000000000000000000000..5795669a50cb844b5ab00d094780a913dce85fda GIT binary patch literal 14606 zcmeHOZBOGk5dNNDF%l;=jc5wHtJ5CrA$6g*i+B~34=WBqnoMbqG|nb2OONHh-`EbR zleWCvmOEHn3AM2&t-{EtovriOXhnR7C+`!CF!-R({wi^0}`t-*}_1QXh94`p+xnvpg%yCZaVs7pr zSfKuaoU14M>%_xx_K!M_15McxV%EG4gPW!zpnk#DPtoJ3I8E?Wor-PFaPP3hG3OFs zgfqFQPMym}d%N9fPe_(CJ4Zf49_rEAd}^0uk+aKrq@;?=d|IvP3R5JCpRThR!j;vQ zp_lr7DJJQ4LUF2F@}_R3x{9>9pu;4Xt$)?2PkGvc3Qn}UX}Kr%h_wdWVa7jv97Y$L z!2xCoi!3b{H%|SCVDaUnh=qU>2Z#tFd!Dt#fW_q2b2!ZFo7h+UU&sIy5}x@ zq=m$^4n3CMin$134`RdsgCCb|UkyeQs*#jz57Q6L?v&K_FNDb7e4ZMr3ilutNmHs+ zSlvclxrAcL*S!p3*@YweI^<~>pF@XTVEix*e&#$;BjnpmF(xI_zG?YBW|`z>=bLSP zy?uCi+S%J`@7C&a%gOQIsPX@x{U^3LeCvmPAI>qxlukNrLlVdp(&iG5*f5oRKPeKH z-o9$r;Tl}mg_}7~VD^wS533i(UIb#5!Z_nz>?0j8Y3c-y+C-u#!hqV}zJIqQK8^Q& zZ>h1o+zlGtH*c}Aywv^eSA2)PrKMmM#zeM<_0{;`e)evO~Vo2aYTBmx|e(M)6DB(^0yh8ch|=Apri zV5M+G9I3+e6CY*5Pi)t19<*BpYW;u*75O2@*V}IaF(2$*b8+SHwn0l z2zIHxG`)cNLjgz<(Hev}4Vq8`;~-(QC&^71v)EzeH9e5YK+u-nRvN7gFVsSFFFSnX zAgCR!EgI+~w(>sKrJHCR1LGJxq3e_%@4{;_j={@x3@Rq0@E!^yl7qamJVmPjD(hiL zf2W0-H_q#WJqwVp==T=SE9x8vrGb|h;9N@{uR0` z7`=qJn356%C6scZOlTz}*1KMCG_2hW<6uB#`>&5gCWvHp+l^i7QWx?|iYyRp9C zs?8hZCr5nFXm9SV&AoMgZ(XgpyY&?&e7`j+^U{m+Y?J*q*>98mmPK-=mTjVz?SYYZ z)uzlFm&^R`!{kms%;2_g2m*!~#FVQQMXTV&BPwmr%g$U?eKoa)YtnMQx9newooujKn5MZVe5 literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/MemoryCreateDto.md b/mobile/openapi/doc/MemoryCreateDto.md new file mode 100644 index 0000000000000000000000000000000000000000..5bcbd54f43e9d39968f7c83094ee38cca061cd6e GIT binary patch literal 682 zcma)4QES355Pr|E2=u`jXno(tz^y(A>SX#>3dZJi*_vKS&VjHWze`orF_=bof%EG`#?`o2Svce{iEXtBuj3(W|3#DGyA5f7} zQB#D<0b0&~<^Bi4WLGtPC^4w)cIE0wdwOY0yKp6*w~0id}Re$fg`5xaVl zIZQPA9EHx&g_J0YXC7+oeHynw)@)YEZDxbF(J-Hm7@HWZb`BcSaY*R~ecG)<=*x5U aa6Z1VmX{-i#yOL}+T z`yBTpljy-|Z^sQ9sK;|?Fnl~h^ynhY2!+Tpd5hXedIs5wq}SD}-hfyxmqrH?w(%H9 z4tD;t9deSeNiaB1(w6iPI2g!66nDA-`6(A4K0PytRj{5cI=1eRSdsTWcJ{11|IZdm z=@@~cQYb6Z*h+2jQlp{VEm#+_AL%28hq#uNJnR$9KZGMNwSwiKea1(^bowcVF_j z$CJnF>1=+@bI%M`+XfuzbaMF`L*5>D9Lb;T>BG&DxqRw5IOEYe+CzL7JSaXALVN+- C;1VJL literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/MemoryType.md b/mobile/openapi/doc/MemoryType.md new file mode 100644 index 0000000000000000000000000000000000000000..c8dea25bed12eb91c5988d865d91d93713bf6706 GIT binary patch literal 376 zcma)1!HNPg487+o0&{RZwBGfk=wZPT5pN<)nMN%;ZAvE|1V7$(R#?}|ZZ6>^FYo15 z$dQ7HPJ6c0>6$#J+x`{_psH}q#-dyZhb>0aewi~t(=^s30p}wp!O?R*eQHgLULlNT zg*qs0Q(h)<)WgPj!aEj|+?Le0$whG-GTN|YhI*+yd_hY6g#iwSMg9|ou`ZdgbG;mo zA6id#tNF5CX>Tjb?bTBdQ+(BllNu8CWBj?EZ$JNeoAW9PHnI!yOXAz`&3p_159S7R A00000 literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/MemoryUpdateDto.md b/mobile/openapi/doc/MemoryUpdateDto.md new file mode 100644 index 0000000000000000000000000000000000000000..7a48e84e885db78bfe1280a9ec933c86a099eea8 GIT binary patch literal 539 zcmbVJ!Ait15WVLs271_SpxwKkD&2!%mqpi8DKu_I4K|&SjKYE+ZxUBr^`Mwb=1tz4 z$Gj9k4rk>|ZAs%)cWB8UZk<+)Et3#M0av6n@FPN|fK&S?^?+g=N3A>yV;y;h#j$^O z9JoqD6Jc?lsCm-X$4AcwgV-pG@Cv(wOIM3w?~P+ab3_~B$f!Th$~JDkGZv*BmLGL(bs2>}v;lGck5csgMNAQx zpi8mCWC9PvvaVOx>!$72(Xi08f3`TYN~u)$IJ-Q>VEA literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1600dfb3318091d310f11cfabc1583bec78d76e6..7d8ab5288913c5e4c76764f7949ff46436d91489 100644 GIT binary patch delta 60 zcmX@=^3ipJ4hvguYHof}<>UvEl71Wz-FUUIi5U&D|%|5wVNCHf+ M7na+cDR@T!0B>~@T>t<8 delta 21 dcmez9demiu4$Ec_775A6$f6*4_8siVL~)jYH~`-x=N*R!Zu-a*;!|XAZO*jU(d|G zFBo4?1ILFDc9`yYd%B1>h^mFaCr2**Mx&(czN817sm%j zhyUE6Zl>p7K9lZYIxs5(%jnO0ZzY z=4ud4^+4i>DVHQ3vI%`gOwxaiQjv107K;x>s5A@pfcrEcA90jcTrC_d;329(Ai_)P zrpGKC{CR&c95bnEH5yKX7=ef|WD#%C-@{t1HVl|j@B$}7&btwZPc_J%>SxrnA8;Ca z4@kg$J#WBn_5_b}-}m;w#}S(bIu)cr07g7y0i1(3Hcb_^^2{0}(_Dnq+XsnyDnkW&jBxe}RYZG+=Per6Do(d~*R{m$ zzropSEqQo(4%~-&ayX4juP4*s{gJN@v^a;0^m|F-a7uH)N6F|NV7?!3s-5#2uE)=` zj*h~SsJn4#*d}DkbGn-iU}_!??O~Wz#6aAINaBc&)}mtIBORXG2StlYb~$2tOot-9 z#hYh)0<PH)CA%F zJVHBzSmBw+qA1`)i=_5DB|?wpW8exgrZSwicz7k=WB25!9&e!8=4SleVAV?=a?4Gc zl08bE^D9l-vIQkA`DwSf>P;d&hdl_VK|lsr_|-ZPCr3vJaBWuc)6xH&EB(kJIxz&Y zn$BLu8ot&79FAmc+Q#F|7}uY*F=JdA9-DSo0Qkt*t@98qa;$o)_Jxnoc?`xnU)z?} z1@m8#uSTUCu?Ye>7b#}OgY`ZRF*3eqk)mciGh>^i*Vx6Axki=tZNw9~w#XsNelte$ zVuZJe&&Y0b!cP$xHGqg5wc$j?ZAES9xc6kI(G`E6N-Ij&)MM= z@ez9?hmqwc;k-#|>h%~?3Cp#7q|@Lm;QwORc;oa8js7NhOVI8a=MCn^hTSf68m%E) zXc_ff7cGoT{gPnDF3eqHM4lEChwSj;n9h2DqLo+4^Z0d+Jy*tm{{i zttH$R{Zz_hd5s0DC;@HDVjIn3v3I|bhi)P^;-(62J&VLRvdiJwk)S22ucerS-t%6+ zR|?Tq#x(AZz92TML|Q&Y;~L$H3)bUQm@w-{#8Y0(?kf+Z)`L#IHBxe@Sv&f;3658S z`yIOYW>Cz);q{@?o3SSV3B26E1}7l(RQUxU2Xk+1;k?(>(pg zrtLTFGD;bLib_o0pQ>9~Y3b&=)skE$aaPqPa^&?z<*Oz4-Kyd;xoz|JrlPsc-!G}8 zY;^v<5JG)<^i)ZXC}eUtwwF<=q8BWz7?pl^k@-7P>YJVMxRudO3>544E1`<1*cO3P zFM?%>OE1b4!FCHX7~4Q){p+t)>@KgrR{Q?I`fKIor7w5lD|v6jE%r3>irY9*bPf+c5J^|+dlshrri$kONaO1o1vt~CGkpr!3gOgF#6_ie`Dq!N>$*Byu! r^Jb9`gi0fnp;R)3CH$FJUa&dyGE)hhx3VU%Wzg`PZUDy5t{+!s` literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4a145d0c4420c7e3f65274ec872b84e89aec5503..8784ad641fc283e9970b4a0c5671c4dda1fc6c48 100644 GIT binary patch delta 96 zcmX@GpK;^5dNNDF+~+gbjAffoKV0ih?Y}1XihYobc&*AYj2tb$7^=iL{aH~znOip z9VZ3ur2BT2n%KKDv-9%I%jV>ycX9%+K29f(emlQB|L|&hegraz28ps5H+OJm+g(m?*r9bt#oO$iear z+v-JGnT6tCDxr|QU~Bv=nZj?wl}6!8A7)Qwp$#j{LS%TLSPHHzyFPeWE()#}xh!sw z%&b_kc=mXau9(uj9v){vEsZoxyUpmVC{j~JhHpNBaSR%hPidiCG-{h z%Cke1Ypd@o)1ua#7hz#ntl5^F2=9hyFTgvY&5J9eL~%2Qpqbs7Tz}%J0aC-hqX&?l z{P0K4ndVSPgBX~iNl+^EqcU62D_P|ktWZ_BMjeo-cvMt*&aQKQ1XrR+Ihw~ompsMz zrOXIf0Kv%%&m`9*8oin3RmRcV8DFw0H}I9^6^B8kQCTbGf!`TDs5KZyu#y}8m8-~- z-0*@cjAQ1-Ygs8&A(be3U1+$Hi9 zVPLUf*Ir7*m%>o`$WawRkVyqeGVpmL5#?$K$rxO%N?AUjz(?SBzI}5~81-(ofQJuJ zGQc9^ygdI>v0RvKjP;V`dSKb>A`;a}I}BWofbtgAvH82Z<55PN;nAPxI0N8_pnl$= zaPFaf|FpA9DMNeoz2`1b7`WjEGfl5vQxYa)YiiwIsH+q_(_L*q(jR36+yx0s$<6)c!NxSeVNNrW^vSx_NnWmpHCN?Mq= zByq~>ajD6bfg7c$VtX%k5_y2ixxbJFqX5LPfFZ%-FL3^)NvWnMu2UFL-tA@;Ylw$! zt;QCVG}R4ml-!W85zRQ74|jx)IUN_&hn(ll%#;f4)kNG8j9=Et4-oUq%cL;LLm9m)@L`3!J8%~1sNkp~7@6%#5g zB)n&dpg(U;!HAM=clOf8Z3@BHql)` zox_~sng(91v|ZV=c~fCXBB6$qz!q;Ih4u1Mn)7vOwm~EU87Gs0mn=6lp%QDeAE55i zAn9gM<1Iv#_l%8vojH5;jkcW;F%HWvzgnEmufe}NUiMr@b}$@VW~H=4fGJz|qpr6I z%m)JzYs^vDijtf z|JF&;-7`1BtZ<1^hwkN_7(8-R$mAOfU8eA6NVj+thG`m#e+vzSpuGed6Fu!5#Kq4h zD9UIf=I}}X1~)$miJyU&ulKFw{~>zNtlbEA!VptmMu-W*VnQhB2nA`ZcFvK;yBL0Sw+=O)lvQ?L+T=ObN`t6K_4Lj&H3u YT=#N)p}+PQ>%M=tJ%FBZn8gC~H<_>@82|tP literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..9180994582eef88acdea7e6400489fbedec6f9ec GIT binary patch literal 8649 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 B`x*cM literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/memory_type.dart b/mobile/openapi/lib/model/memory_type.dart new file mode 100644 index 0000000000000000000000000000000000000000..513b7c2d457d46ea119ae5799e8f00d6c237268c GIT binary patch literal 2461 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 s6<m45s-zn$OBR`UcF%jC)w&u)+ST7)g>p zkpjF{&R%O9XcFe1Qt^$QOj^NKsSn@<#9}l+SCds*G!wq*SdUK(mT}BHU<+Trc)r4 zwnnr@qr>k;F*52p`V>cd0%}LKGtf2WfK(rRwA5sv`~po|`+L$I z@_5~YbD?a1pWEio{%bV$nf}q{evhW0k*Iv*PZ(-D_k+{uK+o3z literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/memory_create_dto_test.dart b/mobile/openapi/test/memory_create_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..f2909bd462932037c5f4a864e53f17e454ac702c GIT binary patch literal 1090 zcmbV~!A{#i5Qgu5is>ndlw!)Mf>JfYp^5}pD(<1GDl^z)XTjcecgLwL#JhKPO)fN2 zNDm(GSl|5r%#PzYiep%O&(il_mp99Ai)@*|p8ddyH{4qHMSBv zwDM-MPQ>{Kkzk)L{U_7B43IQC%+cvGJ{kSl>}YK1}B~{or3>$2E zSyQ+*`1xxl+7LPxaT5-}Yl6+e+MxKIjHrgywyr43MQs8bqGQPQ%Ca{(%aH z5*hazc<3PUdt>Cr+%0aX+^hb=9b}Nmx-!)gv|)b>-s`dj|Ifw|={J~+*g+QK510)d S0ZZ6Bhlk!~AW~uB&(U8i4^RI9 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/memory_response_dto_test.dart b/mobile/openapi/test/memory_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..da25bbb6e7f27d93bd3475f8265f946c320f5521 GIT binary patch literal 1601 zcmb7@Uu)Yi6vf~DDej(fJo-M9_ykjhoU!`z# zb$<5oJPAv+*2ZD6GOk#u3NN71jYnBvg*9rL?$@fe#?jFVQN4(*FKk1F!@rGkT&_jU zcPkt}37uzg=Z}|{Mtc&P3MJW4trWUg?vI*PN~13-qt}elM(Eg@M~Sz#Vr=qw6|TbP2x?e6xn|_Q3`^6xaV@< zJ2^=>XPm?Cr7W%=^Hcu3EAtHYho?M+tbkKd!m-E>``4?0Me?DREdzmJ)2x?^$oC7("/memories", { + ...opts + })); +} +export function createMemory({ memoryCreateDto }: { + memoryCreateDto: MemoryCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: MemoryResponseDto; + }>("/memories", oazapfts.json({ + ...opts, + method: "POST", + body: memoryCreateDto + }))); +} +export function deleteMemory({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/memories/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getMemory({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MemoryResponseDto; + }>(`/memories/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateMemory({ id, memoryUpdateDto }: { + id: string; + memoryUpdateDto: MemoryUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MemoryResponseDto; + }>(`/memories/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: memoryUpdateDto + }))); +} +export function removeMemoryAssets({ id, bulkIdsDto }: { + id: string; + bulkIdsDto: BulkIdsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: BulkIdResponseDto[]; + }>(`/memories/${encodeURIComponent(id)}/assets`, oazapfts.json({ + ...opts, + method: "DELETE", + body: bulkIdsDto + }))); +} +export function addMemoryAssets({ id, bulkIdsDto }: { + id: string; + bulkIdsDto: BulkIdsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: BulkIdResponseDto[]; + }>(`/memories/${encodeURIComponent(id)}/assets`, oazapfts.json({ + ...opts, + method: "PUT", + body: bulkIdsDto + }))); +} export function startOAuth({ oAuthConfigDto }: { oAuthConfigDto: OAuthConfigDto; }, opts?: Oazapfts.RequestOpts) { @@ -2842,6 +2945,12 @@ export enum LibraryType { Upload = "UPLOAD", External = "EXTERNAL" } +export enum Type2 { + OnThisDay = "on_this_day" +} +export enum MemoryType { + OnThisDay = "on_this_day" +} export enum SearchSuggestionType { Country = "country", State = "state", diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 00cf7bbab7..ce51aa4c01 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -10,6 +10,7 @@ import { DownloadController } from 'src/controllers/download.controller'; import { FaceController } from 'src/controllers/face.controller'; import { JobController } from 'src/controllers/job.controller'; import { LibraryController } from 'src/controllers/library.controller'; +import { MemoryController } from 'src/controllers/memory.controller'; import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; @@ -36,6 +37,7 @@ export const controllers = [ FaceController, JobController, LibraryController, + MemoryController, OAuthController, PartnerController, SearchController, diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts new file mode 100644 index 0000000000..771d705942 --- /dev/null +++ b/server/src/controllers/memory.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { MemoryService } from 'src/services/memory.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Memory') +@Controller('memories') +@Authenticated() +export class MemoryController { + constructor(private service: MemoryService) {} + + @Get() + searchMemories(@Auth() auth: AuthDto): Promise { + return this.service.search(auth); + } + + @Post() + createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Get(':id') + getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + updateMemory( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: MemoryUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.remove(auth, id); + } + + @Put(':id/assets') + addMemoryAssets( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: BulkIdsDto, + ): Promise { + return this.service.addAssets(auth, id, dto); + } + + @Delete(':id/assets') + @HttpCode(HttpStatus.OK) + removeMemoryAssets( + @Auth() auth: AuthDto, + @Body() dto: BulkIdsDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.removeAssets(auth, id, dto); + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index 8d021031e9..72644870d3 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -33,6 +33,10 @@ export enum Permission { TIMELINE_READ = 'timeline.read', TIMELINE_DOWNLOAD = 'timeline.download', + MEMORY_READ = 'memory.read', + MEMORY_WRITE = 'memory.write', + MEMORY_DELETE = 'memory.delete', + PERSON_READ = 'person.read', PERSON_WRITE = 'person.write', PERSON_MERGE = 'person.merge', @@ -259,6 +263,18 @@ export class AccessCore { return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } + case Permission.MEMORY_READ: { + return this.repository.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_WRITE: { + return this.repository.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return this.repository.memory.checkOwnerAccess(auth.user.id, ids); + } + case Permission.PERSON_READ: { return await this.repository.person.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts new file mode 100644 index 0000000000..ecd62785f8 --- /dev/null +++ b/server/src/dtos/memory.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; + +class MemoryBaseDto { + @ValidateBoolean({ optional: true }) + isSaved?: boolean; + + @ValidateDate({ optional: true }) + seenAt?: Date; +} + +class OnThisDayDto { + @IsInt() + @IsPositive() + year!: number; +} + +type MemoryData = OnThisDayDto; + +export class MemoryUpdateDto extends MemoryBaseDto { + @ValidateDate({ optional: true }) + memoryAt?: Date; +} + +export class MemoryCreateDto extends MemoryBaseDto { + @IsEnum(MemoryType) + @ApiProperty({ enum: MemoryType, enumName: 'MemoryType' }) + type!: MemoryType; + + @IsObject() + @ValidateNested() + @Type((options) => { + switch (options?.object.type) { + case MemoryType.ON_THIS_DAY: { + return OnThisDayDto; + } + + default: { + return Object; + } + } + }) + data!: MemoryData; + + @ValidateDate() + memoryAt!: Date; + + @ValidateUUID({ optional: true, each: true }) + assetIds?: string[]; +} + +export class MemoryResponseDto { + id!: string; + createdAt!: Date; + updatedAt!: Date; + deletedAt?: Date; + memoryAt!: Date; + seenAt?: Date; + ownerId!: string; + type!: MemoryType; + data!: MemoryData; + isSaved!: boolean; + assets!: AssetResponseDto[]; +} + +export const mapMemory = (entity: MemoryEntity): MemoryResponseDto => { + return { + id: entity.id, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + deletedAt: entity.deletedAt, + memoryAt: entity.memoryAt, + seenAt: entity.seenAt, + ownerId: entity.ownerId, + type: entity.type, + data: entity.data, + isSaved: entity.isSaved, + assets: entity.assets.map((asset) => mapAsset(asset)), + }; +}; diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 4b568cd9c1..761b476930 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -9,6 +9,7 @@ import { AuditEntity } from 'src/entities/audit.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { LibraryEntity } from 'src/entities/library.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; import { MoveEntity } from 'src/entities/move.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; @@ -32,6 +33,7 @@ export const entities = [ AuditEntity, ExifEntity, GeodataPlacesEntity, + MemoryEntity, MoveEntity, PartnerEntity, PersonEntity, diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts new file mode 100644 index 0000000000..d7dcff4b80 --- /dev/null +++ b/server/src/entities/memory.entity.ts @@ -0,0 +1,67 @@ +import { AssetEntity } from 'src/entities/asset.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinTable, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum MemoryType { + /** pictures taken on this day X years ago */ + ON_THIS_DAY = 'on_this_day', +} + +export type OnThisDayData = { year: number }; + +export interface MemoryData { + [MemoryType.ON_THIS_DAY]: OnThisDayData; +} + +@Entity('memories') +export class MemoryEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt?: Date; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + owner!: UserEntity; + + @Column() + ownerId!: string; + + @Column() + type!: T; + + @Column({ type: 'jsonb' }) + data!: MemoryData[T]; + + /** unless set to true, will be automatically deleted in the future */ + @Column({ default: false }) + isSaved!: boolean; + + /** memories are sorted in ascending order by this value */ + @Column({ type: 'timestamptz' }) + memoryAt!: Date; + + /** when the user last viewed the memory */ + @Column({ type: 'timestamptz', nullable: true }) + seenAt?: Date; + + @ManyToMany(() => AssetEntity) + @JoinTable() + assets!: AssetEntity[]; +} diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index 7924a29dd3..8b9bdcc4b5 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -32,6 +32,10 @@ export interface IAccessRepository { checkPartnerAccess(userId: string, partnerIds: Set): Promise>; }; + memory: { + checkOwnerAccess(userId: string, memoryIds: Set): Promise>; + }; + person: { checkFaceOwnerAccess(userId: string, assetFaceId: Set): Promise>; checkOwnerAccess(userId: string, personIds: Set): Promise>; diff --git a/server/src/interfaces/memory.interface.ts b/server/src/interfaces/memory.interface.ts new file mode 100644 index 0000000000..505e1662cb --- /dev/null +++ b/server/src/interfaces/memory.interface.ts @@ -0,0 +1,14 @@ +import { MemoryEntity } from 'src/entities/memory.entity'; + +export const IMemoryRepository = 'IMemoryRepository'; + +export interface IMemoryRepository { + search(ownerId: string): Promise; + get(id: string): Promise; + create(memory: Partial): Promise; + update(memory: Partial): Promise; + delete(id: string): Promise; + getAssetIds(id: string, assetIds: string[]): Promise>; + addAssetIds(id: string, assetIds: string[]): Promise; + removeAssetIds(id: string, assetIds: string[]): Promise; +} diff --git a/server/src/migrations/1711637874206-AddMemoryTable.ts b/server/src/migrations/1711637874206-AddMemoryTable.ts new file mode 100644 index 0000000000..6309cb5082 --- /dev/null +++ b/server/src/migrations/1711637874206-AddMemoryTable.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMemoryTable1711637874206 implements MigrationInterface { + name = 'AddMemoryTable1711637874206' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "memories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "ownerId" uuid NOT NULL, "type" character varying NOT NULL, "data" jsonb NOT NULL, "isSaved" boolean NOT NULL DEFAULT false, "memoryAt" TIMESTAMP WITH TIME ZONE NOT NULL, "seenAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aaa0692d9496fe827b0568612f8" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "memories_assets_assets" ("memoriesId" uuid NOT NULL, "assetsId" uuid NOT NULL, CONSTRAINT "PK_fcaf7112a013d1703c011c6793d" PRIMARY KEY ("memoriesId", "assetsId"))`); + await queryRunner.query(`CREATE INDEX "IDX_984e5c9ab1f04d34538cd32334" ON "memories_assets_assets" ("memoriesId") `); + await queryRunner.query(`CREATE INDEX "IDX_6942ecf52d75d4273de19d2c16" ON "memories_assets_assets" ("assetsId") `); + await queryRunner.query(`ALTER TABLE "memories" ADD CONSTRAINT "FK_575842846f0c28fa5da46c99b19" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e" FOREIGN KEY ("memoriesId") REFERENCES "memories"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f"`); + await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e"`); + await queryRunner.query(`ALTER TABLE "memories" DROP CONSTRAINT "FK_575842846f0c28fa5da46c99b19"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6942ecf52d75d4273de19d2c16"`); + await queryRunner.query(`DROP INDEX "public"."IDX_984e5c9ab1f04d34538cd32334"`); + await queryRunner.query(`DROP TABLE "memories_assets_assets"`); + await queryRunner.query(`DROP TABLE "memories"`); + } + +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index a0c4e19275..0e1cab6d0b 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -196,6 +196,20 @@ WHERE ) AND ("LibraryEntity"."deletedAt" IS NULL) +-- AccessRepository.memory.checkOwnerAccess +SELECT + "MemoryEntity"."id" AS "MemoryEntity_id" +FROM + "memories" "MemoryEntity" +WHERE + ( + ( + ("MemoryEntity"."id" IN ($1)) + AND ("MemoryEntity"."ownerId" = $2) + ) + ) + AND ("MemoryEntity"."deletedAt" IS NULL) + -- AccessRepository.person.checkOwnerAccess SELECT "PersonEntity"."id" AS "PersonEntity_id" diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql new file mode 100644 index 0000000000..aa3df240c1 --- /dev/null +++ b/server/src/queries/memory.repository.sql @@ -0,0 +1,18 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- MemoryRepository.getAssetIds +SELECT + "memories_assets"."assetsId" AS "assetId" +FROM + "memories_assets_assets" "memories_assets" +WHERE + "memories_assets"."memoriesId" = $1 + AND "memories_assets"."assetsId" IN ($2) + +-- MemoryRepository.removeAssetIds +DELETE FROM "memories_assets_assets" +WHERE + ( + "memoriesId" = $1 + AND "assetsId" IN ($2) + ) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 37b5be0e81..fd74eb2ec9 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -5,6 +5,7 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; @@ -19,6 +20,7 @@ type IAssetAccess = IAccessRepository['asset']; type IAuthDeviceAccess = IAccessRepository['authDevice']; type ILibraryAccess = IAccessRepository['library']; type ITimelineAccess = IAccessRepository['timeline']; +type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; type IPartnerAccess = IAccessRepository['partner']; @@ -345,6 +347,28 @@ class TimelineAccess implements ITimelineAccess { } } +class MemoryAccess implements IMemoryAccess { + constructor(private memoryRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, memoryIds: Set): Promise> { + if (memoryIds.size === 0) { + return new Set(); + } + + return this.memoryRepository + .find({ + select: { id: true }, + where: { + id: In([...memoryIds]), + ownerId: userId, + }, + }) + .then((memories) => new Set(memories.map((memory) => memory.id))); + } +} + class PersonAccess implements IPersonAccess { constructor( private assetFaceRepository: Repository, @@ -416,6 +440,7 @@ export class AccessRepository implements IAccessRepository { asset: IAssetAccess; authDevice: IAuthDeviceAccess; library: ILibraryAccess; + memory: IMemoryAccess; person: IPersonAccess; partner: IPartnerAccess; timeline: ITimelineAccess; @@ -425,6 +450,7 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(AssetEntity) assetRepository: Repository, @InjectRepository(AlbumEntity) albumRepository: Repository, @InjectRepository(LibraryEntity) libraryRepository: Repository, + @InjectRepository(MemoryEntity) memoryRepository: Repository, @InjectRepository(PartnerEntity) partnerRepository: Repository, @InjectRepository(PersonEntity) personRepository: Repository, @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository, @@ -436,6 +462,7 @@ export class AccessRepository implements IAccessRepository { this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.authDevice = new AuthDeviceAccess(tokenRepository); this.library = new LibraryAccess(libraryRepository); + this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); this.timeline = new TimelineAccess(partnerRepository); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 4ed114216b..336d5df0f0 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -13,6 +13,7 @@ import { IJobRepository } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -42,6 +43,7 @@ import { JobRepository } from 'src/repositories/job.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MediaRepository } from 'src/repositories/media.repository'; +import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetricRepository } from 'src/repositories/metric.repository'; import { MoveRepository } from 'src/repositories/move.repository'; @@ -72,6 +74,7 @@ export const repositories = [ { provide: ILibraryRepository, useClass: LibraryRepository }, { provide: IKeyRepository, useClass: ApiKeyRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, + { provide: IMemoryRepository, useClass: MemoryRepository }, { provide: IMetadataRepository, useClass: MetadataRepository }, { provide: IMetricRepository, useClass: MetricRepository }, { provide: IMoveRepository, useClass: MoveRepository }, diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts new file mode 100644 index 0000000000..ae8346d009 --- /dev/null +++ b/server/src/repositories/memory.repository.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { Instrumentation } from 'src/utils/instrumentation'; +import { DataSource, In, Repository } from 'typeorm'; + +@Instrumentation() +@Injectable() +export class MemoryRepository implements IMemoryRepository { + constructor( + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(MemoryEntity) private repository: Repository, + @InjectDataSource() private dataSource: DataSource, + ) {} + + search(ownerId: string): Promise { + return this.repository.find({ + where: { + ownerId, + }, + order: { + memoryAt: 'DESC', + }, + }); + } + + get(id: string): Promise { + return this.repository.findOne({ + where: { + id, + }, + relations: { + assets: true, + }, + }); + } + + create(memory: Partial): Promise { + return this.save(memory); + } + + update(memory: Partial): Promise { + return this.save(memory); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @ChunkedSet({ paramIndex: 1 }) + async getAssetIds(id: string, assetIds: string[]): Promise> { + if (assetIds.length === 0) { + return new Set(); + } + + const results = await this.dataSource + .createQueryBuilder() + .select('memories_assets.assetsId', 'assetId') + .from('memories_assets_assets', 'memories_assets') + .where('"memories_assets"."memoriesId" = :memoryId', { memoryId: id }) + .andWhere('memories_assets.assetsId IN (:...assetIds)', { assetIds }) + .getRawMany(); + + return new Set(results.map((row) => row['assetId'])); + } + + @GenerateSql({ params: [{ albumId: DummyValue.UUID, assetIds: [DummyValue.UUID] }] }) + async addAssetIds(id: string, assetIds: string[]): Promise { + await this.dataSource + .createQueryBuilder() + .insert() + .into('memories_assets_assets', ['memoriesId', 'assetsId']) + .values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId }))) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async removeAssetIds(id: string, assetIds: string[]): Promise { + await this.dataSource + .createQueryBuilder() + .delete() + .from('memories_assets_assets') + .where({ + memoriesId: id, + assetsId: In(assetIds), + }) + .execute(); + } + + private async save(memory: Partial): Promise { + const { id } = await this.repository.save(memory); + return this.repository.findOneOrFail({ + where: { id }, + relations: { + assets: true, + }, + }); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 5a97d16fec..3c903c927d 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -11,6 +11,7 @@ import { DownloadService } from 'src/services/download.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; import { MediaService } from 'src/services/media.service'; +import { MemoryService } from 'src/services/memory.service'; import { MetadataService } from 'src/services/metadata.service'; import { MicroservicesService } from 'src/services/microservices.service'; import { PartnerService } from 'src/services/partner.service'; @@ -42,6 +43,7 @@ export const services = [ JobService, LibraryService, MediaService, + MemoryService, MetadataService, PartnerService, PersonService, diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts new file mode 100644 index 0000000000..5f045ffde8 --- /dev/null +++ b/server/src/services/memory.service.spec.ts @@ -0,0 +1,214 @@ +import { BadRequestException } from '@nestjs/common'; +import { MemoryType } from 'src/entities/memory.entity'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { MemoryService } from 'src/services/memory.service'; +import { authStub } from 'test/fixtures/auth.stub'; +import { memoryStub } from 'test/fixtures/memory.stub'; +import { userStub } from 'test/fixtures/user.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock'; + +describe(MemoryService.name, () => { + let accessMock: IAccessRepositoryMock; + let memoryMock: jest.Mocked; + let sut: MemoryService; + + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + memoryMock = newMemoryRepositoryMock(); + + sut = new MemoryService(accessMock, memoryMock); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('search', () => { + it('should search memories', async () => { + memoryMock.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); + await expect(sut.search(authStub.admin)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), + expect.objectContaining({ id: 'memoryEmpty', assets: [] }), + ]), + ); + }); + + it('should map ', async () => { + await expect(sut.search(authStub.admin)).resolves.toEqual([]); + }); + }); + + describe('get', () => { + it('should throw an error when no access', async () => { + await expect(sut.get(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should throw an error when the memory is not found', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); + await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should get a memory by id', async () => { + memoryMock.get.mockResolvedValue(memoryStub.memory1); + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' }); + expect(memoryMock.get).toHaveBeenCalledWith('memory1'); + expect(accessMock.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); + }); + }); + + describe('create', () => { + it('should skip assets the user does not have access to', async () => { + memoryMock.create.mockResolvedValue(memoryStub.empty); + await expect( + sut.create(authStub.admin, { + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + assetIds: ['not-mine'], + memoryAt: new Date(2024), + }), + ).resolves.toMatchObject({ assets: [] }); + expect(memoryMock.create).toHaveBeenCalledWith(expect.objectContaining({ assets: [] })); + }); + + it('should create a memory', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + memoryMock.create.mockResolvedValue(memoryStub.memory1); + await expect( + sut.create(authStub.admin, { + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + assetIds: ['asset1'], + memoryAt: new Date(2024), + }), + ).resolves.toBeDefined(); + expect(memoryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + ownerId: userStub.admin.id, + assets: [{ id: 'asset1' }], + }), + ); + }); + + it('should create a memory without assets', async () => { + memoryMock.create.mockResolvedValue(memoryStub.memory1); + await expect( + sut.create(authStub.admin, { + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + memoryAt: new Date(2024), + }), + ).resolves.toBeDefined(); + }); + }); + + describe('update', () => { + it('should require access', async () => { + await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(memoryMock.update).not.toHaveBeenCalled(); + }); + + it('should update a memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.update.mockResolvedValue(memoryStub.memory1); + await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined(); + expect(memoryMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'memory1', + isSaved: true, + }), + ); + }); + }); + + describe('remove', () => { + it('should require access', async () => { + await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); + expect(memoryMock.delete).not.toHaveBeenCalled(); + }); + + it('should delete a memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined(); + expect(memoryMock.delete).toHaveBeenCalledWith('memory1'); + }); + }); + + describe('addAssets', () => { + it('should require memory access', async () => { + await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should require asset access', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.get.mockResolvedValue(memoryStub.memory1); + await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ + { error: 'no_permission', id: 'not-found', success: false }, + ]); + expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should skip assets already in the memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.get.mockResolvedValue(memoryStub.memory1); + memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { error: 'duplicate', id: 'asset1', success: false }, + ]); + expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should add assets', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + memoryMock.get.mockResolvedValue(memoryStub.memory1); + await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { id: 'asset1', success: true }, + ]); + expect(memoryMock.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + }); + }); + + describe('removeAssets', () => { + it('should require memory access', async () => { + await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + }); + + it('should skip assets not in the memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ + { error: 'not_found', id: 'not-found', success: false }, + ]); + expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + }); + + it('should require asset access', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { error: 'no_permission', id: 'asset1', success: false }, + ]); + expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + }); + + it('should remove assets', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { id: 'asset1', success: true }, + ]); + expect(memoryMock.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + }); + }); +}); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts new file mode 100644 index 0000000000..a73eb3ec04 --- /dev/null +++ b/server/src/services/memory.service.ts @@ -0,0 +1,105 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; + +@Injectable() +export class MemoryService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) private accessRepository: IAccessRepository, + @Inject(IMemoryRepository) private repository: IMemoryRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async search(auth: AuthDto) { + const memories = await this.repository.search(auth.user.id); + return memories.map((memory) => mapMemory(memory)); + } + + async get(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + const memory = await this.findOrFail(id); + return mapMemory(memory); + } + + async create(auth: AuthDto, dto: MemoryCreateDto) { + // TODO validate type/data combination + + const assetIds = dto.assetIds || []; + const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, assetIds); + const memory = await this.repository.create({ + ownerId: auth.user.id, + type: dto.type, + data: dto.data, + isSaved: dto.isSaved, + memoryAt: dto.memoryAt, + seenAt: dto.seenAt, + assets: [...allowedAssetIds].map((id) => ({ id }) as AssetEntity), + }); + + return mapMemory(memory); + } + + async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + + const memory = await this.repository.update({ + id, + isSaved: dto.isSaved, + memoryAt: dto.memoryAt, + seenAt: dto.seenAt, + }); + + return mapMemory(memory); + } + + async remove(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id); + await this.repository.delete(id); + } + + async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + + const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const results = await addAssets(auth, repos, { id, assetIds: dto.ids }); + + const hasSuccess = results.find(({ success }) => success); + if (hasSuccess) { + await this.repository.update({ id, updatedAt: new Date() }); + } + + return results; + } + + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + + const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const permissions = [Permission.ASSET_SHARE]; + const results = await removeAssets(auth, repos, { id, assetIds: dto.ids, permissions }); + + const hasSuccess = results.find(({ success }) => success); + if (hasSuccess) { + await this.repository.update({ id, updatedAt: new Date() }); + } + + return results; + } + + private async findOrFail(id: string) { + const memory = await this.repository.get(id); + if (!memory) { + throw new BadRequestException('Memory not found'); + } + return memory; + } +} diff --git a/server/test/fixtures/memory.stub.ts b/server/test/fixtures/memory.stub.ts new file mode 100644 index 0000000000..bb84a8f1df --- /dev/null +++ b/server/test/fixtures/memory.stub.ts @@ -0,0 +1,30 @@ +import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { userStub } from 'test/fixtures/user.stub'; + +export const memoryStub = { + empty: { + id: 'memoryEmpty', + createdAt: new Date(), + updatedAt: new Date(), + memoryAt: new Date(2024), + ownerId: userStub.admin.id, + owner: userStub.admin, + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + isSaved: false, + assets: [], + }, + memory1: { + id: 'memory1', + createdAt: new Date(), + updatedAt: new Date(), + memoryAt: new Date(2024), + ownerId: userStub.admin.id, + owner: userStub.admin, + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + isSaved: false, + assets: [assetStub.image1], + }, +}; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index a614512774..fe7de7c833 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -8,6 +8,7 @@ export interface IAccessRepositoryMock { authDevice: jest.Mocked; library: jest.Mocked; timeline: jest.Mocked; + memory: jest.Mocked; person: jest.Mocked; partner: jest.Mocked; } @@ -49,6 +50,10 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), }, + memory: { + checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), + }, + person: { checkFaceOwnerAccess: jest.fn().mockResolvedValue(new Set()), checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), diff --git a/server/test/repositories/memory.repository.mock.ts b/server/test/repositories/memory.repository.mock.ts new file mode 100644 index 0000000000..85b17a1985 --- /dev/null +++ b/server/test/repositories/memory.repository.mock.ts @@ -0,0 +1,14 @@ +import { IMemoryRepository } from 'src/interfaces/memory.interface'; + +export const newMemoryRepositoryMock = (): jest.Mocked => { + return { + search: jest.fn().mockResolvedValue([]), + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getAssetIds: jest.fn().mockResolvedValue(new Set()), + addAssetIds: jest.fn(), + removeAssetIds: jest.fn(), + }; +};