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(),
+  };
+};