From cd0e537e3ea44db4d081bbdeb1dfcff8fcf8cd8a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> 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{Fg<H>Dd;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&<MG&@XJ$Ms0823TlF$`@J2+tJsFj44wXgu&%nN|`5nB73ka+%u zcaGNC*;(MF+zJN?OF7i?_LZDXQ?L;PwN*iCS*<97ql3*1yc8kD$m8K90%za|Qpd7( zkoQ>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!<EdYz->3b{H%|SCVDaUnh=qU>2Z#<qeYPQHP}2gwEc^9;~yt7oHF4kciWE zfg|vSbTHEJZ#pq7UICx6ix915Gr*oVj5wXr^(e%cyR^_PqNSgqFNr@;-xiKU<C<Ml zFV^bfgi|a04z(|PM+c`g&goYB3msIFVHo>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#<L|`rQROf8sehtlQ2BQUQmx4q;=!z2(B%l zk9s-9jO+!ys7vYIW71Y7gM_vPw-p1L$_<c#q4lwml&ule+E+eGsHO$Pn5d#i?59Ex zjkU^fEAR{c8H=58qx=M6KKH%U8xS<6nPu(LFPcqfl!!0Q8320RWgN4|c?I-V$!o9( z0m6V#RUp6GT#oZyFc&>zeM<_0{;`e)evO~Vo2aYTBmx|e(M)6DB(^0yh8ch|=Apri zV5M+G9I3+e6CY*5Pi)t19<*B<J8j{^sK)vt9OpKo(x0~q0(CYk{i-n^vE+R9(F^1v zK!bEa$ixX48e%CNXN7iA66HYC{3|2xOj3y>pYW;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=SE9x8vrG<v?L3YJJLxM32t<hk*?y@`!OfRQj2-EcjitJ4^ zZ=(69%6Ayhe%;Z$hS*{&+@=iNstNj`P+MpAEFgGBoIM#ZUouLQ*f>b|h;9N@{uR0` z7`=qJn356%C6scZOlTz}*1KMCG_2h<ObvyLeuJVW^fjTcT{AiHC&`IxD0)|hyJU{$ z9p@IzUlzhrO8Tb?Ty<ovfbK+|`UNBJv>W<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)YtnMQ<on)N5;m9X z#i!=1brKmQ59I#^9LY*^Q^IBP*d~u{^4P^iZBd=onqO^q#o!b}6eg%t09Ld<q5$k( j8#ix!)(5-~o&V=b?~3-z<za=g$5>x9newooujKn5MZVe5 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_<OL+~vFP z<G#B@VhfFKZRXHGolU`^-@ka!1UaD)Ng^kxjih&w9YnUe`qWzxtJTVAPr|wuJ;~lN ze}5z#MA8W8Jw#~7H23`I$wCk_?LdCZ<-U!-975%7OBNfBW=M=kFFsCQL}~h8ER@nA z0F6yhior@vY@o*ldE0ECx)f&%>=bof%EG`#?`o2Svce{iEXtBuj3(W|3#DGyA5f7} z<z41XpX>QB#D<0b0&~<^Bi4WLGtPC^4w)cIE0wdwOY0yKp6*w~0id}Re$fg`5xaVl zIZQPA9EHx&g_J0YXC7+oeHynw)@)YEZDxbF(J-Hm7@HWZb`BcSaY*R~ecG)<=*x5U aa6Z1VmX{-i#<XY~x}rD;{u1vCA$|aYbJN)X literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/MemoryResponseDto.md b/mobile/openapi/doc/MemoryResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..ef379be044c8f788b4c0fc5efe25b55e60ebf32b GIT binary patch literal 890 zcmb7?-%G<V5Xay1R|NX7Ezs><MVQP7!KvupN?}=Zo6$BGlA9v<$9t(Ob`x>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=@@~cQY<Z$y0wumrpeXo?oz38c<3BAX2dxu&A?i3Ym$Vr;(?GX%96=d08L<~)WK(L zE^Gq{*Zu!sIKybto%nwB0u`s0!+i<Va00Zzc38SDCG)Lm=}J9Oux`^D+(3%NC;bLp zc!gN^f=#>b6Z*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!<EY@VR1`V9*yO}J4n zsHKctDes7;Tt?JWHd507k!SP94d>Th$~JDkGZv*BmLGL(bs2>}v;lGck5csgMNAQx zpi8mCWC9PvvaVOx>!$72(Xi08f3`TYN~u)$<g1*2DB)AxY%l+)jlWUhtR0z|zl?Y{ K{AOMXA>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}<>Uv<Vw-hXBzPwq3d>El71Wz-FUUIi5U&D|%|5wVNCHf+ M7na+cDR@T!0B>~@T>t<8 delta 21 dcmez9demiu4$Ec_775<Tt%B;C#f5GO003JC2U-9C diff --git a/mobile/openapi/lib/api/memory_api.dart b/mobile/openapi/lib/api/memory_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..6b4a619b52e1ada0af571c79a9e758f253b92837 GIT binary patch literal 11601 zcmeHNZF3Sy5dO}uX#L>A6$f6*4_8siVL~)jYH~`-x=N*R!Zu-a*;!|XAZO*jU(d|G zFBo4?1ILFDc9`yYd%B<L?rpbg?KX5z`^UdO?Y->1>h^mFaCr2**Mx&(czN817sm%j zhyUE6Zl>p7K9l<KPV0}Int9}xp^!Kj2{{<?0C!;;P8IqC3~+*>ZYIxs5(%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)cr07g7y0i<o2g#EQmTYZ9(wt1p}h48H3KLPLL zv=8lxnHWda{>1(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<<JX0UKgB2<o2MT(3zJYS{BwG^9O2g4U1)Zm{EvcQ_<iL_+B&!k^QS+3v(@ z+_8U{qMV<YV^GxT(ckG~=j{ApZsKEPKFX}4+omSC|D8|Q`OXF`pY3WD+l>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&<G0U70T43g#s>&Y0b!cP$xHG<HUFFKWM^?`i~ibArzY2t{D)xHh#|<a93N8 zsQCnq$V_rg-j3S1YQkGXbi0Hh8cx5Z0Z5HoBKhCDry+if$hy%7BnL~<JR}CYo2Wh{ z<6}DA37DBhXgRm~MKTxCgch^P`dK%z!U>qg5wc$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*t<j?(reGL4oHEkDoG zhMQP%XeFJN0wtCdLrL$d8PWSLo-0l6e@AwNt+ivj62Q*O2wRL?CzT^J;<7x3>m{{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<I7Rp^A%VPX0mcTu&P)q;=U*MYWFcnd^7*uvyL;Nq%`b2Zm#DWs;pgU4 zzSSoXUN+7lR2Z0UjwvL+);FYr>);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>i<R26VR5}H zsSI>rY9*bPf+c5J^|+dlshrri$kONaO1o1vt~CGkpr!3gOgF#6_ie`Dq!N>$*Byu! r^Jb9`gi0fnp;R)3CH$FJUa&dyGE)hhx3VU%Wz<zM>g`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;<L#to-TCQp)<W%EtV%`d8)%<rr@`G`pZQ*Qobflx)5Xrrn&CqzL=Wx?c+ cN}@1{^Y+H9V2R1L(sEE%g0$9VCDVCY0OE!t8~^|S delta 18 acmbPui1EOF#to-TCd-&cZ7wyNqXhs^+y~_V diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 9d2d86cba5a0029f93e8bd27712f0efc34cee8b8..7ad74d9516f0aa99ce8e13aa049a30a9ca0fedc5 100644 GIT binary patch delta 45 ycmeyT|G;2F5w}cgZhlc^NM%8)rb0EB0uU6XmXsFdDfj{<CKqtIZZ72R<pBVO*ANQ; delta 12 TcmaE0@K1k35%=aR+&w%1DI*1y diff --git a/mobile/openapi/lib/model/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..5d08a631ca134f084af705e67b53a0dd9f9b82a0 GIT binary patch literal 4909 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 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 zcmai0+iu%N5PjEIjDaGS0hMa=6gW{F1D0DCiCrY}Lm>#nnp|4jP`m8z(gG3q-+N}4 zH_NUoz!WLY&gIORGoH^U^EsX0u5Nz*ZF#-?dcIm-(B;+V<%}+F==x?wUv4h0F8?}$ z8C!nJh4qWK$**rG{HX4=u`(@;O$$}ZQ>u0CWS+`W?xgm!?pf_BWBrRKM7bA@ORL(a zR{m2fi{o1CaIJ*Jx0lwzxpgle&y03nXrHPa0ac--4aeP!!Ahm2OG~5gVdl3&r|*7A zv#qdhGC?>`rXW*EEh?4Z@BL&l$x7iIeUUq3n^jXu`Y|DzqxP#9SP3slkd}Lry57-V z`7Qaaq{^BHB$d<pL6o)JBsdqc9XU(VsoyG>1d9`di>_BVUh7=ivQhe;!dp&_ZDW+3 z+}%mbE(@haN#DHfErXj+=0RGE_%LZ-dJ}J^C$#t&od(nX^&igY!<PWptE^4j=oK=! z5DhJ!7GaN#2A;~LxoD1uLOv@OFcoD<m2pnpl`=+lq;#xj&9H6O1Hu8RCvgJ+4naQ* zRv*`>jHpj(hR02)r#(8Do>5`VE>xE{;1=qMfHMw*8FzPOg?6CxPTJ{7lFQ8Gaysfx z>znHu=RzfZxI7H<H}IU%k2_;Zy0>O8xcUVZqI7chcoLY@HPA{|m;SIs6vRcUf~M?@ z(Eb4Fn>WPIiE`J*FL#x1I&bi2EKMUT5R+kM)8Ty=F|z7OBYT)|5KZLJ{NeD?`e;GA zF3YhqqL_Lj^1MeXo+El}eZ=p0wbIvCcc;AJf~Fljr#(<1{^R74b!JX|U%*D-5Qh>2 z>?sqPKp}3_hH$UKSZ0#vWlSparX7S?@F+AUGd6fb9W8R<1>K44J6n%WC(m@?fGwe! zg~&W);x$e&<<dUKv$MrBO*n82iHP%bs2%@h*N$_9WIE8BxVSL-d=n+7SDcH$bO#|@ zcUXjuZ`N$QVK~I7EEXQLC0^+YNMpjy{u<oaFiE|wA&Nklh45vM=$kg4?j*$~<mBos zlg<@z((p8M-*H|C%CXnT9GNHdyV*<V+l;As5IRiP>#<wk|C2$C&Fs3>*k5s@$$V+8 zv9ZW4#za4bf|dIqA>`Q*R!!c}o~wb!^FOZ$TL)VRhy$h3`iOIeVlhtG9hhZ{)$<_p z4iUD;j<v9`lAKqN*4nZ7!?7vhtg413#6R$8aFzoV&RWc;U9Y*xol^Ckgcxxjd!b?( zU_*MQ0uD<r+s3zJZODX^^mI3zwp|=$lk^%sNKP&b0X;lL+TDl4AN9$N!?1<tX3)94 zulXO3(4^_{t^TgjqPQ|6vLJHg+bR9T#qhG~BD|_stb1$<L5kG;2Fy$8p++O2I2cOs Vq?IFXz_Z14V0|_y!+RFKe*vHXF6;mR literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/memory_update_dto.dart b/mobile/openapi/lib/model/memory_update_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..adf42330df3a3b3959178b13a2e85a1d64a74a02 GIT binary patch literal 4591 zcmeHLTW=FN6n@XII7Nu0+93rVR;Z8^L<=fQn-wi9t)eP&XPm|`8E0(|QB?Zh_dC8M zlQe}2Y4?eTrm20d-?_Sbd#$}adj08q@c7-)>Cwm6=SPS1_V~k5Mu!7B9h}q2;PCkE z-#Z{<%2$OjuJ^3_^jVAlR9BTYax~UvG*+eDr@E@GEJm`FQ(3tz{jH`mZQNE4QO?D} zj%KwR8Tn7G4A?6%#pg^I{C3h<2sd^+d#)?%MCC@RfI>A^(s*{eRavPjX-B25u0ZA{ zq8dGaGRh~y*j5Yb9O*IXnXJT2b@B6})yhj@EuF}zHj7WQLO6NoG+nod_Tc|V>1tD1 z5>&}K4L8UxNO|LO*#-T^Wrt-Dxrwq}WzWP{S!An|saN>gHIA&5RXDiWg<;-+@JWA` zQdmiqb_l^aB+|^l4dWJMC%P^Rnm|5XLIQ}Y$5maH;<A)Gbf&6Y!U#!rCUd}-3#W8N z9L*@a(9*JKIFpxkA>nKx$D%GBeHCRbsZ(23U3no7;?4epr3JegP4rxTl_v8fSF(}@ zFcwj~)wO{MxrXFvZ5@q8S<<D*zYu_i-7FjUMw7`cW*$z4{|$nVxEm5h5y-X}0K4yS z&?^8xt^paN$8mySd|ql@9#9O-FSvcS|A%lNA|uY#R31>u{9l;=f0&2X;t_wPQK<$+ z5o>_kA<9)7>h(H<%g-`LlSIETyVUDbXu7#5J6u9py7~4kd_(<gwmo|E2$Ct>r^W}_ zsnpi*Q!+5Y^81>EU^?I5;e@IjT}g*dVkd97$@Kc(faUb!p;gITS}V+ghYse*L|SD% zStqst*1$c84(JK(Cu?`QN$f8D7U-8|t}~?LEgUNs8VbT;gZfr<&YbaWSBEbhACUIR z`lK30^Lr1|j9KE?dzfs>B6hupLF+}^D?1UhmqB($#R4-~<$biw_f}U>7RJ|DRidQN zIHDfiqQ1U);XzSj@(Hqx_f7yaNg*#`Xxh=p_64LhCu0zEL$bt}3z`wXZCngtq+VB@ zoTa;TCxVsobh6WR`jg8$yZC)<_4D7KJWZH?->@XWi@eja82`KM3fC*EQfB<5O$~9I z78$?IcvRW1o491$d&?nF6%<xIOUl`>A6Ob2J%v1;*h9ZEU@;nlpv{ZZ5NURih1&9s zWv*<T!*RXb$1WkqSlIloXX+f?$d0&Ajj;MTm5;C`PISQq8xA&`&Ve_<CvHsSU|PyQ z&d;#RaKjR_bWYfV8u{%>y1B6KR;G8tO2(ZnqIj}ih#jAed>?aspQd8=7cWqQ@i}&y zm%MTGJ6uW`ttw$RY=F#|vdMF%fw^Qzw{7NbH`qeJw=>0LSZ9GNtnS8tMsHH8_?(C! ze^3Bo!_7XGIDLzNub~~hx@H&Y%IG;SpD1yxdRAsP3GM*4k(PH$w3AKQ!;4@Uhl?fh zcwr+jGknsby_{-yG@ZFc5{U?6bakR+J?CYRSy%Td#v%T_&D7~~@_Jujc0`8JS*6Ua zy5;NOL>GH$NUR2Dz%}8!hH~U`n!V~Ip!S!Lc`3~hG+Xp0TAW{71Ce|Wpn$5`x;LdX zTX@18TE7dqr|X4~+*K{;2CMQ;54{oIT6j|O(=klnweaE8EK&fw{Fjn8Im4g_hkh7H zFQ@Ct8e#cCKmpS*Fn*B>9#5V><Rk0;PE^6#Xr~+&?t3029Tuv&auXbJ>F^75BL<HH z6_{d2<#!7nVf<c&utwKw3~bJzTLsi-dcN737a85JKp~@<G21o$Oy1lxCGL}6vD-F^ z|A*NFx7H)P5r!^@X9-**iLzyVt|~QKW>!dywK9jMMndf2#V(hzPyYiUq?yGIBoJeM s6<<o8f);AI0tCH38=UfMHTGgZ8qsyaKT!T)-sL{lzXlyPF*J^U0pMZCumAu6 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/memory_api_test.dart b/mobile/openapi/test/memory_api_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..1a930782eaa74aa72c2d42c294591347ba0f4275 GIT binary patch literal 1444 zcmbtTQE!_t5Ps)Z+@3-tR=Pe-Z5k~r$rPzAQkOpT3Ay+}tb*;?rc*Tazt1+5u!gA1 z_JHB+@4oN5;~d999K-B>m45s-zn$OBR`UcF%j<arNeZ{=3U1P5xp??sVGj9)5@jyW zPClIlE@TB43bVCP*_xGj4mGbe(hN(ikelc=W|b7m3}%q>C)w&u)+ST<qh`vcb5hw= z5@p{8mA2$c52xpXYeTrn7`1|I%~1JtJt$T(jyfv^FD%S#2+uyBX8DFF9R!U>7)g>p zkpj<!L{21&?Q`O)9$oA~3~Cd*<rUzX5`){(V4DIrSP8YANftZ{Mxi2%hhz8^09e?X zaZ&>F{&R%O9XcFe1Qt^$QOj^NKsSn@<#9}l+SCds*G!wq*SdUK(mT}BHU<+Trc)r4 zwnnr@qr>k;F<t<p5j1dZaq#Ff-RBsH-tyc!0AEXE;Vn($!6SV|4sEMirAg{DUz#F% z1{)K7Z;&e_2AhMhd0qJ}=cw}C>*52p`V>cd0%}LKGtf2WfK(rRwA5sv`~po|`+L$I z@_5~YbD?a1pWEio{%bV$nf}q{evhW0k*Iv*PZ(-D_k+{<C+c|lePyU6-3@2=gQE7H W-uu(toOba6gMGHYwCAqSOYj>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)@*|<?3oVha`oYG=u9jSzZ2kCzvO{lxX?< z<mkgm<Wp5^V=1qU%`4T=DfGH`ROZxBOPc4~T(zCCyt4wEKRCF&>p8ddyH{4qHMSBv zwDM-MPQ<O-U0xXN7&Ygr6hl=hvZ37VG;5S5mp4Y&g7F%4{_!|3)@WT6jXlDo!{QNZ zIyD_>>{Kkzk)L{U_7B43IQC%+cvGJ{kSl>}YK<K(EFn|ECfXQPLW@dIXYdpO2wW+R z4X7|fL2&-MW;#2IgsiRUyXnMBza}Pgc##W&SIY6HTeeEqp8=gCUX>1}B~{or3>$2E zSyQ+*`1xxl+7LPxaT5-}Yl6<o^p*#>+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(<Kvr*k8tpn3a5@5Q4Q=)?#&A{ZCeda~nsnU~O27NA<irdT zi2dL{GQWGyJy&v?CTR+@@5}uCxB1=tezu%vaJ{&hPa(_UE?>fJo-M9_ykjhoU!`z# zb$<5oJPAv+*2ZD6GOk#u3NN71jYnBvg*9rL?$@fe#?jFVQN4(*FKk1F!@rGkT&_jU zcPkt}37uzg=Z}|{Mtc&P3MJW4trWUg?vI*PN~13-qt}elM(E<xhoamF=aZ!E5k^*& zf5aLuj72T1O8GhqsYjOw2%)Dm-1a~)4QmfP61c_Mxa~c9%S~cNCV4hWLM@aYPvA8H zV0@*ts6fROCBoHV)p&B5FuQi9vExyYKV(Kz*hxmw8|CTq%zLE&+`oVbUp$N@u0&H2 zJd3K~O^Y*xpT8#T0<nL#G4TaxgJ2!c!qFBUTY<;cP57-YV~kM7QI>g@M~Sz#<M=TX zgiBQ;ln(h-&gi5|M-DQ|pMGOWAO(q&4gne@-ldRywELy#$U#OA<dj~6k|#4LIY{`) z7(QnHK|Es*?Uoy4(2b~$$G*ot@4-Pfy8)ROjc#!|$&MRj(IxSSx<`%{8)UIdL0gL_ aH#$Ir#Jm0*D|`NO9of%_dyfD3Tk;Pl!s46& literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/memory_type_test.dart b/mobile/openapi/test/memory_type_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..0a6589d9add0e14dbbd627c7755814a63cec0a55 GIT binary patch literal 417 zcmZvYQA@)x6oudOE3Qx7pf0*6*$^DGlYv`7>Vr=qw6|TbP2x?e6xn|_Q3`^6xaV@< zJ2^=>XPm?Cr7W%=^Hcu3EAtHYho?M+tbkKd!m-E>``4?0Me?DR<nPz3+ck?;wb~Gh z#*k=KhZ`95;IJ0hVUOCUlUKbp<YyDI`;?;-cJPAmeNYtQTJ}M$q_91b3x|`Nk5@)J zFSQq{4uz^wNI!8mTh=L!PIN}M0rO|6#odjl&XOEszjuVh%IZzFxG@&Bw2FsW#aAcW ze~3rtd6Wr=rO`VGUjj$$4UOe!k$o6dLobzHEa8hWfR@Z)7fFQwa7hXia9M5{yRdIp C6^%Op literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/memory_update_dto_test.dart b/mobile/openapi/test/memory_update_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..173128e395aa25793c94704ce79daa2614b0c75b GIT binary patch literal 767 zcmbV|&uhXk6vyxVE8b2jIJ<coiZG!%glS=`hn-^9zSgid$&%C|!~cD+sa~8g*`aA( z`Fwx8hG7tfFkL6{?bB>EdzmJ)2<D6DSs$VpR&fH$I9kl#ZU}40$BY{{84m7;K{JX% zDuXOnhUKEf5!AA_m@zD|Lh1UaSyWmXcTvI1FTS@-*Nz$dtc4-D<Q1Ko8+to5mduU4 zEKilRj!VZxMu8$1XuRBB3@e31%St5+!rYci_Hf72EjKm@+K$lCJpJGWj+92pwFv1M zG+TGZzYvGkh0SpSn$TvpK)(c*SShn#>x?^$oC<bIWKeM-yFK_000_>7<Ru8dLy9mt z*K~X1fXKqAT6a5*^*Pb$!_m$gzAHt3_Lc82BO&-Yq*VR_Zi}D|t!CUFHb-vLdT_g! xsV~;(s9X|9vG+K%E&RubmPtf~Q2Fhq?$4AH_`ev7D6a@<vFjqJvES$r`~b6}^9%q0 literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 16a9ad63af..da7bad8f0d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3435,6 +3435,314 @@ ] } }, + "/memories": { + "get": { + "operationId": "searchMemories", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MemoryResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memory" + ] + }, + "post": { + "operationId": "createMemory", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memory" + ] + } + }, + "/memories/{id}": { + "delete": { + "operationId": "deleteMemory", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memory" + ] + }, + "get": { + "operationId": "getMemory", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memory" + ] + }, + "put": { + "operationId": "updateMemory", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memory" + ] + } + }, + "/memories/{id}/assets": { + "delete": { + "operationId": "removeMemoryAssets", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memory" + ] + }, + "put": { + "operationId": "addMemoryAssets", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memory" + ] + } + }, "/oauth/authorize": { "post": { "operationId": "startOAuth", @@ -8451,6 +8759,40 @@ ], "type": "string" }, + "MemoryCreateDto": { + "properties": { + "assetIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "data": { + "type": "object" + }, + "isSaved": { + "type": "boolean" + }, + "memoryAt": { + "format": "date-time", + "type": "string" + }, + "seenAt": { + "format": "date-time", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/MemoryType" + } + }, + "required": [ + "data", + "memoryAt", + "type" + ], + "type": "object" + }, "MemoryLaneResponseDto": { "properties": { "assets": { @@ -8474,6 +8816,88 @@ ], "type": "object" }, + "MemoryResponseDto": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "data": { + "type": "object" + }, + "deletedAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "isSaved": { + "type": "boolean" + }, + "memoryAt": { + "format": "date-time", + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "seenAt": { + "format": "date-time", + "type": "string" + }, + "type": { + "enum": [ + "on_this_day" + ], + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "assets", + "createdAt", + "data", + "id", + "isSaved", + "memoryAt", + "ownerId", + "type", + "updatedAt" + ], + "type": "object" + }, + "MemoryType": { + "enum": [ + "on_this_day" + ], + "type": "string" + }, + "MemoryUpdateDto": { + "properties": { + "isSaved": { + "type": "boolean" + }, + "memoryAt": { + "format": "date-time", + "type": "string" + }, + "seenAt": { + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, "MergePersonDto": { "properties": { "ids": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e63ccb4d64..1584a79cf1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -494,6 +494,32 @@ export type ValidateLibraryImportPathResponseDto = { export type ValidateLibraryResponseDto = { importPaths?: ValidateLibraryImportPathResponseDto[]; }; +export type MemoryResponseDto = { + assets: AssetResponseDto[]; + createdAt: string; + data: object; + deletedAt?: string; + id: string; + isSaved: boolean; + memoryAt: string; + ownerId: string; + seenAt?: string; + "type": Type2; + updatedAt: string; +}; +export type MemoryCreateDto = { + assetIds?: string[]; + data: object; + isSaved?: boolean; + memoryAt: string; + seenAt?: string; + "type": MemoryType; +}; +export type MemoryUpdateDto = { + isSaved?: boolean; + memoryAt?: string; + seenAt?: string; +}; export type OAuthConfigDto = { redirectUri: string; }; @@ -1908,6 +1934,83 @@ export function validate({ id, validateLibraryDto }: { body: validateLibraryDto }))); } +export function searchMemories(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MemoryResponseDto[]; + }>("/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<MemoryResponseDto[]> { + return this.service.search(auth); + } + + @Post() + createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise<MemoryResponseDto> { + return this.service.create(auth, dto); + } + + @Get(':id') + getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MemoryResponseDto> { + return this.service.get(auth, id); + } + + @Put(':id') + updateMemory( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: MemoryUpdateDto, + ): Promise<MemoryResponseDto> { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { + return this.service.remove(auth, id); + } + + @Put(':id/assets') + addMemoryAssets( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: BulkIdsDto, + ): Promise<BulkIdResponseDto[]> { + return this.service.addAssets(auth, id, dto); + } + + @Delete(':id/assets') + @HttpCode(HttpStatus.OK) + removeMemoryAssets( + @Auth() auth: AuthDto, + @Body() dto: BulkIdsDto, + @Param() { id }: UUIDParamDto, + ): Promise<BulkIdResponseDto[]> { + 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<T extends MemoryType = MemoryType> { + @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<string>): Promise<Set<string>>; }; + memory: { + checkOwnerAccess(userId: string, memoryIds: Set<string>): Promise<Set<string>>; + }; + person: { checkFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>; checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>; 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<MemoryEntity[]>; + get(id: string): Promise<MemoryEntity | null>; + create(memory: Partial<MemoryEntity>): Promise<MemoryEntity>; + update(memory: Partial<MemoryEntity>): Promise<MemoryEntity>; + delete(id: string): Promise<void>; + getAssetIds(id: string, assetIds: string[]): Promise<Set<string>>; + addAssetIds(id: string, assetIds: string[]): Promise<void>; + removeAssetIds(id: string, assetIds: string[]): Promise<void>; +} 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<void> { + 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<void> { + 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<MemoryEntity>) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, memoryIds: Set<string>): Promise<Set<string>> { + 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<AssetFaceEntity>, @@ -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<AssetEntity>, @InjectRepository(AlbumEntity) albumRepository: Repository<AlbumEntity>, @InjectRepository(LibraryEntity) libraryRepository: Repository<LibraryEntity>, + @InjectRepository(MemoryEntity) memoryRepository: Repository<MemoryEntity>, @InjectRepository(PartnerEntity) partnerRepository: Repository<PartnerEntity>, @InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>, @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>, @@ -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<AssetEntity>, + @InjectRepository(MemoryEntity) private repository: Repository<MemoryEntity>, + @InjectDataSource() private dataSource: DataSource, + ) {} + + search(ownerId: string): Promise<MemoryEntity[]> { + return this.repository.find({ + where: { + ownerId, + }, + order: { + memoryAt: 'DESC', + }, + }); + } + + get(id: string): Promise<MemoryEntity | null> { + return this.repository.findOne({ + where: { + id, + }, + relations: { + assets: true, + }, + }); + } + + create(memory: Partial<MemoryEntity>): Promise<MemoryEntity> { + return this.save(memory); + } + + update(memory: Partial<MemoryEntity>): Promise<MemoryEntity> { + return this.save(memory); + } + + async delete(id: string): Promise<void> { + await this.repository.delete({ id }); + } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @ChunkedSet({ paramIndex: 1 }) + async getAssetIds(id: string, assetIds: string[]): Promise<Set<string>> { + 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<void> { + 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<void> { + await this.dataSource + .createQueryBuilder() + .delete() + .from('memories_assets_assets') + .where({ + memoriesId: id, + assetsId: In(assetIds), + }) + .execute(); + } + + private async save(memory: Partial<MemoryEntity>): Promise<MemoryEntity> { + 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<IMemoryRepository>; + 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<MemoryResponseDto> { + 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<MemoryResponseDto> { + 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<void> { + await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id); + await this.repository.delete(id); + } + + async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { + 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<BulkIdResponseDto[]> { + 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: <MemoryEntity>{ + 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: <MemoryEntity>{ + 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<IAccessRepository['authDevice']>; library: jest.Mocked<IAccessRepository['library']>; timeline: jest.Mocked<IAccessRepository['timeline']>; + memory: jest.Mocked<IAccessRepository['memory']>; person: jest.Mocked<IAccessRepository['person']>; partner: jest.Mocked<IAccessRepository['partner']>; } @@ -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<IMemoryRepository> => { + 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(), + }; +};