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 a46f2383e5..fede2901c7 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/MemoryApi.md b/mobile/openapi/doc/MemoryApi.md new file mode 100644 index 0000000000..5795669a50 Binary files /dev/null and b/mobile/openapi/doc/MemoryApi.md differ diff --git a/mobile/openapi/doc/MemoryCreateDto.md b/mobile/openapi/doc/MemoryCreateDto.md new file mode 100644 index 0000000000..5bcbd54f43 Binary files /dev/null and b/mobile/openapi/doc/MemoryCreateDto.md differ diff --git a/mobile/openapi/doc/MemoryResponseDto.md b/mobile/openapi/doc/MemoryResponseDto.md new file mode 100644 index 0000000000..ef379be044 Binary files /dev/null and b/mobile/openapi/doc/MemoryResponseDto.md differ diff --git a/mobile/openapi/doc/MemoryType.md b/mobile/openapi/doc/MemoryType.md new file mode 100644 index 0000000000..c8dea25bed Binary files /dev/null and b/mobile/openapi/doc/MemoryType.md differ diff --git a/mobile/openapi/doc/MemoryUpdateDto.md b/mobile/openapi/doc/MemoryUpdateDto.md new file mode 100644 index 0000000000..7a48e84e88 Binary files /dev/null and b/mobile/openapi/doc/MemoryUpdateDto.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1600dfb331..7d8ab52889 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/memory_api.dart b/mobile/openapi/lib/api/memory_api.dart new file mode 100644 index 0000000000..6b4a619b52 Binary files /dev/null and b/mobile/openapi/lib/api/memory_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4a145d0c44..8784ad641f 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 9d2d86cba5..7ad74d9516 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ 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 0000000000..5d08a631ca Binary files /dev/null and b/mobile/openapi/lib/model/memory_create_dto.dart differ 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 0000000000..9180994582 Binary files /dev/null and b/mobile/openapi/lib/model/memory_response_dto.dart differ diff --git a/mobile/openapi/lib/model/memory_type.dart b/mobile/openapi/lib/model/memory_type.dart new file mode 100644 index 0000000000..513b7c2d45 Binary files /dev/null and b/mobile/openapi/lib/model/memory_type.dart differ 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 0000000000..adf42330df Binary files /dev/null and b/mobile/openapi/lib/model/memory_update_dto.dart differ diff --git a/mobile/openapi/test/memory_api_test.dart b/mobile/openapi/test/memory_api_test.dart new file mode 100644 index 0000000000..1a930782ea Binary files /dev/null and b/mobile/openapi/test/memory_api_test.dart differ 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 0000000000..f2909bd462 Binary files /dev/null and b/mobile/openapi/test/memory_create_dto_test.dart differ 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 0000000000..da25bbb6e7 Binary files /dev/null and b/mobile/openapi/test/memory_response_dto_test.dart differ diff --git a/mobile/openapi/test/memory_type_test.dart b/mobile/openapi/test/memory_type_test.dart new file mode 100644 index 0000000000..0a6589d9ad Binary files /dev/null and b/mobile/openapi/test/memory_type_test.dart differ 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 0000000000..173128e395 Binary files /dev/null and b/mobile/openapi/test/memory_update_dto_test.dart differ 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 { + return this.service.search(auth); + } + + @Post() + createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Get(':id') + getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + updateMemory( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: MemoryUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.remove(auth, id); + } + + @Put(':id/assets') + addMemoryAssets( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: BulkIdsDto, + ): Promise { + return this.service.addAssets(auth, id, dto); + } + + @Delete(':id/assets') + @HttpCode(HttpStatus.OK) + removeMemoryAssets( + @Auth() auth: AuthDto, + @Body() dto: BulkIdsDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.removeAssets(auth, id, dto); + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index 8d021031e9..72644870d3 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -33,6 +33,10 @@ export enum Permission { TIMELINE_READ = 'timeline.read', TIMELINE_DOWNLOAD = 'timeline.download', + MEMORY_READ = 'memory.read', + MEMORY_WRITE = 'memory.write', + MEMORY_DELETE = 'memory.delete', + PERSON_READ = 'person.read', PERSON_WRITE = 'person.write', PERSON_MERGE = 'person.merge', @@ -259,6 +263,18 @@ export class AccessCore { return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } + case Permission.MEMORY_READ: { + return this.repository.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_WRITE: { + return this.repository.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return this.repository.memory.checkOwnerAccess(auth.user.id, ids); + } + case Permission.PERSON_READ: { return await this.repository.person.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts new file mode 100644 index 0000000000..ecd62785f8 --- /dev/null +++ b/server/src/dtos/memory.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; + +class MemoryBaseDto { + @ValidateBoolean({ optional: true }) + isSaved?: boolean; + + @ValidateDate({ optional: true }) + seenAt?: Date; +} + +class OnThisDayDto { + @IsInt() + @IsPositive() + year!: number; +} + +type MemoryData = OnThisDayDto; + +export class MemoryUpdateDto extends MemoryBaseDto { + @ValidateDate({ optional: true }) + memoryAt?: Date; +} + +export class MemoryCreateDto extends MemoryBaseDto { + @IsEnum(MemoryType) + @ApiProperty({ enum: MemoryType, enumName: 'MemoryType' }) + type!: MemoryType; + + @IsObject() + @ValidateNested() + @Type((options) => { + switch (options?.object.type) { + case MemoryType.ON_THIS_DAY: { + return OnThisDayDto; + } + + default: { + return Object; + } + } + }) + data!: MemoryData; + + @ValidateDate() + memoryAt!: Date; + + @ValidateUUID({ optional: true, each: true }) + assetIds?: string[]; +} + +export class MemoryResponseDto { + id!: string; + createdAt!: Date; + updatedAt!: Date; + deletedAt?: Date; + memoryAt!: Date; + seenAt?: Date; + ownerId!: string; + type!: MemoryType; + data!: MemoryData; + isSaved!: boolean; + assets!: AssetResponseDto[]; +} + +export const mapMemory = (entity: MemoryEntity): MemoryResponseDto => { + return { + id: entity.id, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + deletedAt: entity.deletedAt, + memoryAt: entity.memoryAt, + seenAt: entity.seenAt, + ownerId: entity.ownerId, + type: entity.type, + data: entity.data, + isSaved: entity.isSaved, + assets: entity.assets.map((asset) => mapAsset(asset)), + }; +}; diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 4b568cd9c1..761b476930 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -9,6 +9,7 @@ import { AuditEntity } from 'src/entities/audit.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { LibraryEntity } from 'src/entities/library.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; import { MoveEntity } from 'src/entities/move.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; @@ -32,6 +33,7 @@ export const entities = [ AuditEntity, ExifEntity, GeodataPlacesEntity, + MemoryEntity, MoveEntity, PartnerEntity, PersonEntity, diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts new file mode 100644 index 0000000000..d7dcff4b80 --- /dev/null +++ b/server/src/entities/memory.entity.ts @@ -0,0 +1,67 @@ +import { AssetEntity } from 'src/entities/asset.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinTable, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum MemoryType { + /** pictures taken on this day X years ago */ + ON_THIS_DAY = 'on_this_day', +} + +export type OnThisDayData = { year: number }; + +export interface MemoryData { + [MemoryType.ON_THIS_DAY]: OnThisDayData; +} + +@Entity('memories') +export class MemoryEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt?: Date; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + owner!: UserEntity; + + @Column() + ownerId!: string; + + @Column() + type!: T; + + @Column({ type: 'jsonb' }) + data!: MemoryData[T]; + + /** unless set to true, will be automatically deleted in the future */ + @Column({ default: false }) + isSaved!: boolean; + + /** memories are sorted in ascending order by this value */ + @Column({ type: 'timestamptz' }) + memoryAt!: Date; + + /** when the user last viewed the memory */ + @Column({ type: 'timestamptz', nullable: true }) + seenAt?: Date; + + @ManyToMany(() => AssetEntity) + @JoinTable() + assets!: AssetEntity[]; +} diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index 7924a29dd3..8b9bdcc4b5 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -32,6 +32,10 @@ export interface IAccessRepository { checkPartnerAccess(userId: string, partnerIds: Set): Promise>; }; + memory: { + checkOwnerAccess(userId: string, memoryIds: Set): Promise>; + }; + person: { checkFaceOwnerAccess(userId: string, assetFaceId: Set): Promise>; checkOwnerAccess(userId: string, personIds: Set): Promise>; diff --git a/server/src/interfaces/memory.interface.ts b/server/src/interfaces/memory.interface.ts new file mode 100644 index 0000000000..505e1662cb --- /dev/null +++ b/server/src/interfaces/memory.interface.ts @@ -0,0 +1,14 @@ +import { MemoryEntity } from 'src/entities/memory.entity'; + +export const IMemoryRepository = 'IMemoryRepository'; + +export interface IMemoryRepository { + search(ownerId: string): Promise; + get(id: string): Promise; + create(memory: Partial): Promise; + update(memory: Partial): Promise; + delete(id: string): Promise; + getAssetIds(id: string, assetIds: string[]): Promise>; + addAssetIds(id: string, assetIds: string[]): Promise; + removeAssetIds(id: string, assetIds: string[]): Promise; +} diff --git a/server/src/migrations/1711637874206-AddMemoryTable.ts b/server/src/migrations/1711637874206-AddMemoryTable.ts new file mode 100644 index 0000000000..6309cb5082 --- /dev/null +++ b/server/src/migrations/1711637874206-AddMemoryTable.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMemoryTable1711637874206 implements MigrationInterface { + name = 'AddMemoryTable1711637874206' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "memories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "ownerId" uuid NOT NULL, "type" character varying NOT NULL, "data" jsonb NOT NULL, "isSaved" boolean NOT NULL DEFAULT false, "memoryAt" TIMESTAMP WITH TIME ZONE NOT NULL, "seenAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aaa0692d9496fe827b0568612f8" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "memories_assets_assets" ("memoriesId" uuid NOT NULL, "assetsId" uuid NOT NULL, CONSTRAINT "PK_fcaf7112a013d1703c011c6793d" PRIMARY KEY ("memoriesId", "assetsId"))`); + await queryRunner.query(`CREATE INDEX "IDX_984e5c9ab1f04d34538cd32334" ON "memories_assets_assets" ("memoriesId") `); + await queryRunner.query(`CREATE INDEX "IDX_6942ecf52d75d4273de19d2c16" ON "memories_assets_assets" ("assetsId") `); + await queryRunner.query(`ALTER TABLE "memories" ADD CONSTRAINT "FK_575842846f0c28fa5da46c99b19" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e" FOREIGN KEY ("memoriesId") REFERENCES "memories"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f"`); + await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e"`); + await queryRunner.query(`ALTER TABLE "memories" DROP CONSTRAINT "FK_575842846f0c28fa5da46c99b19"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6942ecf52d75d4273de19d2c16"`); + await queryRunner.query(`DROP INDEX "public"."IDX_984e5c9ab1f04d34538cd32334"`); + await queryRunner.query(`DROP TABLE "memories_assets_assets"`); + await queryRunner.query(`DROP TABLE "memories"`); + } + +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index a0c4e19275..0e1cab6d0b 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -196,6 +196,20 @@ WHERE ) AND ("LibraryEntity"."deletedAt" IS NULL) +-- AccessRepository.memory.checkOwnerAccess +SELECT + "MemoryEntity"."id" AS "MemoryEntity_id" +FROM + "memories" "MemoryEntity" +WHERE + ( + ( + ("MemoryEntity"."id" IN ($1)) + AND ("MemoryEntity"."ownerId" = $2) + ) + ) + AND ("MemoryEntity"."deletedAt" IS NULL) + -- AccessRepository.person.checkOwnerAccess SELECT "PersonEntity"."id" AS "PersonEntity_id" diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql new file mode 100644 index 0000000000..aa3df240c1 --- /dev/null +++ b/server/src/queries/memory.repository.sql @@ -0,0 +1,18 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- MemoryRepository.getAssetIds +SELECT + "memories_assets"."assetsId" AS "assetId" +FROM + "memories_assets_assets" "memories_assets" +WHERE + "memories_assets"."memoriesId" = $1 + AND "memories_assets"."assetsId" IN ($2) + +-- MemoryRepository.removeAssetIds +DELETE FROM "memories_assets_assets" +WHERE + ( + "memoriesId" = $1 + AND "assetsId" IN ($2) + ) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 37b5be0e81..fd74eb2ec9 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -5,6 +5,7 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; @@ -19,6 +20,7 @@ type IAssetAccess = IAccessRepository['asset']; type IAuthDeviceAccess = IAccessRepository['authDevice']; type ILibraryAccess = IAccessRepository['library']; type ITimelineAccess = IAccessRepository['timeline']; +type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; type IPartnerAccess = IAccessRepository['partner']; @@ -345,6 +347,28 @@ class TimelineAccess implements ITimelineAccess { } } +class MemoryAccess implements IMemoryAccess { + constructor(private memoryRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, memoryIds: Set): Promise> { + if (memoryIds.size === 0) { + return new Set(); + } + + return this.memoryRepository + .find({ + select: { id: true }, + where: { + id: In([...memoryIds]), + ownerId: userId, + }, + }) + .then((memories) => new Set(memories.map((memory) => memory.id))); + } +} + class PersonAccess implements IPersonAccess { constructor( private assetFaceRepository: Repository, @@ -416,6 +440,7 @@ export class AccessRepository implements IAccessRepository { asset: IAssetAccess; authDevice: IAuthDeviceAccess; library: ILibraryAccess; + memory: IMemoryAccess; person: IPersonAccess; partner: IPartnerAccess; timeline: ITimelineAccess; @@ -425,6 +450,7 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(AssetEntity) assetRepository: Repository, @InjectRepository(AlbumEntity) albumRepository: Repository, @InjectRepository(LibraryEntity) libraryRepository: Repository, + @InjectRepository(MemoryEntity) memoryRepository: Repository, @InjectRepository(PartnerEntity) partnerRepository: Repository, @InjectRepository(PersonEntity) personRepository: Repository, @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository, @@ -436,6 +462,7 @@ export class AccessRepository implements IAccessRepository { this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.authDevice = new AuthDeviceAccess(tokenRepository); this.library = new LibraryAccess(libraryRepository); + this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); this.timeline = new TimelineAccess(partnerRepository); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 4ed114216b..336d5df0f0 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -13,6 +13,7 @@ import { IJobRepository } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -42,6 +43,7 @@ import { JobRepository } from 'src/repositories/job.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MediaRepository } from 'src/repositories/media.repository'; +import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetricRepository } from 'src/repositories/metric.repository'; import { MoveRepository } from 'src/repositories/move.repository'; @@ -72,6 +74,7 @@ export const repositories = [ { provide: ILibraryRepository, useClass: LibraryRepository }, { provide: IKeyRepository, useClass: ApiKeyRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, + { provide: IMemoryRepository, useClass: MemoryRepository }, { provide: IMetadataRepository, useClass: MetadataRepository }, { provide: IMetricRepository, useClass: MetricRepository }, { provide: IMoveRepository, useClass: MoveRepository }, diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts new file mode 100644 index 0000000000..ae8346d009 --- /dev/null +++ b/server/src/repositories/memory.repository.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { Instrumentation } from 'src/utils/instrumentation'; +import { DataSource, In, Repository } from 'typeorm'; + +@Instrumentation() +@Injectable() +export class MemoryRepository implements IMemoryRepository { + constructor( + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(MemoryEntity) private repository: Repository, + @InjectDataSource() private dataSource: DataSource, + ) {} + + search(ownerId: string): Promise { + return this.repository.find({ + where: { + ownerId, + }, + order: { + memoryAt: 'DESC', + }, + }); + } + + get(id: string): Promise { + return this.repository.findOne({ + where: { + id, + }, + relations: { + assets: true, + }, + }); + } + + create(memory: Partial): Promise { + return this.save(memory); + } + + update(memory: Partial): Promise { + return this.save(memory); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @ChunkedSet({ paramIndex: 1 }) + async getAssetIds(id: string, assetIds: string[]): Promise> { + if (assetIds.length === 0) { + return new Set(); + } + + const results = await this.dataSource + .createQueryBuilder() + .select('memories_assets.assetsId', 'assetId') + .from('memories_assets_assets', 'memories_assets') + .where('"memories_assets"."memoriesId" = :memoryId', { memoryId: id }) + .andWhere('memories_assets.assetsId IN (:...assetIds)', { assetIds }) + .getRawMany(); + + return new Set(results.map((row) => row['assetId'])); + } + + @GenerateSql({ params: [{ albumId: DummyValue.UUID, assetIds: [DummyValue.UUID] }] }) + async addAssetIds(id: string, assetIds: string[]): Promise { + await this.dataSource + .createQueryBuilder() + .insert() + .into('memories_assets_assets', ['memoriesId', 'assetsId']) + .values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId }))) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async removeAssetIds(id: string, assetIds: string[]): Promise { + await this.dataSource + .createQueryBuilder() + .delete() + .from('memories_assets_assets') + .where({ + memoriesId: id, + assetsId: In(assetIds), + }) + .execute(); + } + + private async save(memory: Partial): Promise { + const { id } = await this.repository.save(memory); + return this.repository.findOneOrFail({ + where: { id }, + relations: { + assets: true, + }, + }); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 5a97d16fec..3c903c927d 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -11,6 +11,7 @@ import { DownloadService } from 'src/services/download.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; import { MediaService } from 'src/services/media.service'; +import { MemoryService } from 'src/services/memory.service'; import { MetadataService } from 'src/services/metadata.service'; import { MicroservicesService } from 'src/services/microservices.service'; import { PartnerService } from 'src/services/partner.service'; @@ -42,6 +43,7 @@ export const services = [ JobService, LibraryService, MediaService, + MemoryService, MetadataService, PartnerService, PersonService, diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts new file mode 100644 index 0000000000..5f045ffde8 --- /dev/null +++ b/server/src/services/memory.service.spec.ts @@ -0,0 +1,214 @@ +import { BadRequestException } from '@nestjs/common'; +import { MemoryType } from 'src/entities/memory.entity'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { MemoryService } from 'src/services/memory.service'; +import { authStub } from 'test/fixtures/auth.stub'; +import { memoryStub } from 'test/fixtures/memory.stub'; +import { userStub } from 'test/fixtures/user.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock'; + +describe(MemoryService.name, () => { + let accessMock: IAccessRepositoryMock; + let memoryMock: jest.Mocked; + let sut: MemoryService; + + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + memoryMock = newMemoryRepositoryMock(); + + sut = new MemoryService(accessMock, memoryMock); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('search', () => { + it('should search memories', async () => { + memoryMock.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); + await expect(sut.search(authStub.admin)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), + expect.objectContaining({ id: 'memoryEmpty', assets: [] }), + ]), + ); + }); + + it('should map ', async () => { + await expect(sut.search(authStub.admin)).resolves.toEqual([]); + }); + }); + + describe('get', () => { + it('should throw an error when no access', async () => { + await expect(sut.get(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should throw an error when the memory is not found', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); + await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should get a memory by id', async () => { + memoryMock.get.mockResolvedValue(memoryStub.memory1); + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' }); + expect(memoryMock.get).toHaveBeenCalledWith('memory1'); + expect(accessMock.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); + }); + }); + + describe('create', () => { + it('should skip assets the user does not have access to', async () => { + memoryMock.create.mockResolvedValue(memoryStub.empty); + await expect( + sut.create(authStub.admin, { + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + assetIds: ['not-mine'], + memoryAt: new Date(2024), + }), + ).resolves.toMatchObject({ assets: [] }); + expect(memoryMock.create).toHaveBeenCalledWith(expect.objectContaining({ assets: [] })); + }); + + it('should create a memory', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + memoryMock.create.mockResolvedValue(memoryStub.memory1); + await expect( + sut.create(authStub.admin, { + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + assetIds: ['asset1'], + memoryAt: new Date(2024), + }), + ).resolves.toBeDefined(); + expect(memoryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + ownerId: userStub.admin.id, + assets: [{ id: 'asset1' }], + }), + ); + }); + + it('should create a memory without assets', async () => { + memoryMock.create.mockResolvedValue(memoryStub.memory1); + await expect( + sut.create(authStub.admin, { + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + memoryAt: new Date(2024), + }), + ).resolves.toBeDefined(); + }); + }); + + describe('update', () => { + it('should require access', async () => { + await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(memoryMock.update).not.toHaveBeenCalled(); + }); + + it('should update a memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.update.mockResolvedValue(memoryStub.memory1); + await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined(); + expect(memoryMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'memory1', + isSaved: true, + }), + ); + }); + }); + + describe('remove', () => { + it('should require access', async () => { + await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); + expect(memoryMock.delete).not.toHaveBeenCalled(); + }); + + it('should delete a memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined(); + expect(memoryMock.delete).toHaveBeenCalledWith('memory1'); + }); + }); + + describe('addAssets', () => { + it('should require memory access', async () => { + await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should require asset access', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.get.mockResolvedValue(memoryStub.memory1); + await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ + { error: 'no_permission', id: 'not-found', success: false }, + ]); + expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should skip assets already in the memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.get.mockResolvedValue(memoryStub.memory1); + memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { error: 'duplicate', id: 'asset1', success: false }, + ]); + expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should add assets', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + memoryMock.get.mockResolvedValue(memoryStub.memory1); + await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { id: 'asset1', success: true }, + ]); + expect(memoryMock.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + }); + }); + + describe('removeAssets', () => { + it('should require memory access', async () => { + await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + }); + + it('should skip assets not in the memory', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ + { error: 'not_found', id: 'not-found', success: false }, + ]); + expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + }); + + it('should require asset access', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { error: 'no_permission', id: 'asset1', success: false }, + ]); + expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + }); + + it('should remove assets', async () => { + accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ + { id: 'asset1', success: true }, + ]); + expect(memoryMock.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + }); + }); +}); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts new file mode 100644 index 0000000000..a73eb3ec04 --- /dev/null +++ b/server/src/services/memory.service.ts @@ -0,0 +1,105 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; + +@Injectable() +export class MemoryService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) private accessRepository: IAccessRepository, + @Inject(IMemoryRepository) private repository: IMemoryRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async search(auth: AuthDto) { + const memories = await this.repository.search(auth.user.id); + return memories.map((memory) => mapMemory(memory)); + } + + async get(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + const memory = await this.findOrFail(id); + return mapMemory(memory); + } + + async create(auth: AuthDto, dto: MemoryCreateDto) { + // TODO validate type/data combination + + const assetIds = dto.assetIds || []; + const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, assetIds); + const memory = await this.repository.create({ + ownerId: auth.user.id, + type: dto.type, + data: dto.data, + isSaved: dto.isSaved, + memoryAt: dto.memoryAt, + seenAt: dto.seenAt, + assets: [...allowedAssetIds].map((id) => ({ id }) as AssetEntity), + }); + + return mapMemory(memory); + } + + async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + + const memory = await this.repository.update({ + id, + isSaved: dto.isSaved, + memoryAt: dto.memoryAt, + seenAt: dto.seenAt, + }); + + return mapMemory(memory); + } + + async remove(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id); + await this.repository.delete(id); + } + + async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + + const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const results = await addAssets(auth, repos, { id, assetIds: dto.ids }); + + const hasSuccess = results.find(({ success }) => success); + if (hasSuccess) { + await this.repository.update({ id, updatedAt: new Date() }); + } + + return results; + } + + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + + const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const permissions = [Permission.ASSET_SHARE]; + const results = await removeAssets(auth, repos, { id, assetIds: dto.ids, permissions }); + + const hasSuccess = results.find(({ success }) => success); + if (hasSuccess) { + await this.repository.update({ id, updatedAt: new Date() }); + } + + return results; + } + + private async findOrFail(id: string) { + const memory = await this.repository.get(id); + if (!memory) { + throw new BadRequestException('Memory not found'); + } + return memory; + } +} diff --git a/server/test/fixtures/memory.stub.ts b/server/test/fixtures/memory.stub.ts new file mode 100644 index 0000000000..bb84a8f1df --- /dev/null +++ b/server/test/fixtures/memory.stub.ts @@ -0,0 +1,30 @@ +import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { userStub } from 'test/fixtures/user.stub'; + +export const memoryStub = { + empty: { + id: 'memoryEmpty', + createdAt: new Date(), + updatedAt: new Date(), + memoryAt: new Date(2024), + ownerId: userStub.admin.id, + owner: userStub.admin, + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + isSaved: false, + assets: [], + }, + memory1: { + id: 'memory1', + createdAt: new Date(), + updatedAt: new Date(), + memoryAt: new Date(2024), + ownerId: userStub.admin.id, + owner: userStub.admin, + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 }, + isSaved: false, + assets: [assetStub.image1], + }, +}; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index a614512774..fe7de7c833 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -8,6 +8,7 @@ export interface IAccessRepositoryMock { authDevice: jest.Mocked; library: jest.Mocked; timeline: jest.Mocked; + memory: jest.Mocked; person: jest.Mocked; partner: jest.Mocked; } @@ -49,6 +50,10 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), }, + memory: { + checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), + }, + person: { checkFaceOwnerAccess: jest.fn().mockResolvedValue(new Set()), checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), diff --git a/server/test/repositories/memory.repository.mock.ts b/server/test/repositories/memory.repository.mock.ts new file mode 100644 index 0000000000..85b17a1985 --- /dev/null +++ b/server/test/repositories/memory.repository.mock.ts @@ -0,0 +1,14 @@ +import { IMemoryRepository } from 'src/interfaces/memory.interface'; + +export const newMemoryRepositoryMock = (): jest.Mocked => { + return { + search: jest.fn().mockResolvedValue([]), + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getAssetIds: jest.fn().mockResolvedValue(new Set()), + addAssetIds: jest.fn(), + removeAssetIds: jest.fn(), + }; +};