diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index a13bb58eb1..ddc8dd3ef6 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -5,7 +5,6 @@ import { LibraryResponseDto, LoginResponseDto, SharedLinkType, - TimeBucketSize, getAllLibraries, getAssetInfo, updateAssets, @@ -942,146 +941,6 @@ describe('/asset', () => { }); }); - describe('GET /asset/time-buckets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should get time buckets by month', async () => { - const { status, body } = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]), - ); - }); - - it('should not allow access for unrelated shared links', async () => { - const sharedLink = await utils.createSharedLink(user1.accessToken, { - type: SharedLinkType.Individual, - assetIds: user1Assets.map(({ id }) => id), - }); - - const { status, body } = await request(app) - .get('/asset/time-buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.Month }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should get time buckets by day', async () => { - const { status, body } = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Day }); - - expect(status).toBe(200); - expect(body).toEqual([ - { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]); - }); - }); - - describe('GET /asset/time-bucket', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/time-bucket').query({ - size: TimeBucketSize.Month, - timeBucket: '1900-01-01T00:00:00.000Z', - }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should handle 5 digit years', async () => { - const { status, body } = await request(app) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - // TODO enable date string validation while still accepting 5 digit years - // it('should fail if time bucket is invalid', async () => { - // const { status, body } = await request(app) - // .get('/asset/time-bucket') - // .set('Authorization', `Bearer ${user1.accessToken}`) - // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); - - // expect(status).toBe(400); - // expect(body).toEqual(errorDto.badRequest); - // }); - - it('should return time bucket', async () => { - const { status, body } = await request(app) - .get('/asset/time-bucket') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - it('should return error if time bucket is requested with partners asset and archived', async () => { - const req1 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and favorite', async () => { - const req1 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and trash', async () => { - const req = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); - - expect(req.status).toBe(400); - expect(req.body).toEqual(errorDto.badRequest()); - }); - }); - describe('GET /asset', () => { it('should return stack data', async () => { const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`); diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts new file mode 100644 index 0000000000..84daa19f44 --- /dev/null +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -0,0 +1,193 @@ +import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +// TODO this should probably be a test util function +const today = DateTime.fromObject({ + year: 2023, + month: 11, + day: 3, +}) as DateTime; +const yesterday = today.minus({ days: 1 }); + +describe('/timeline', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let timeBucketUser: LoginResponseDto; + + let userAssets: AssetFileUploadResponseDto[]; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + [user, timeBucketUser] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('1')), + utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), + ]); + + userAssets = await Promise.all([ + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken, { + isFavorite: true, + isReadOnly: true, + fileCreatedAt: yesterday.toISO(), + fileModifiedAt: yesterday.toISO(), + assetData: { filename: 'example.mp4' }, + }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + + await Promise.all([ + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + ]); + }); + + describe('GET /timeline/buckets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get time buckets by month', async () => { + const { status, body } = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]), + ); + }); + + it('should not allow access for unrelated shared links', async () => { + const sharedLink = await utils.createSharedLink(user.accessToken, { + type: SharedLinkType.Individual, + assetIds: userAssets.map(({ id }) => id), + }); + + const { status, body } = await request(app) + .get('/timeline/buckets') + .query({ key: sharedLink.key, size: TimeBucketSize.Month }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should get time buckets by day', async () => { + const { status, body } = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Day }); + + expect(status).toBe(200); + expect(body).toEqual([ + { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]); + }); + + it('should return error if time bucket is requested with partners asset and archived', async () => { + const req1 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${user.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and favorite', async () => { + const req1 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and trash', async () => { + const req = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${user.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); + + expect(req.status).toBe(400); + expect(req.body).toEqual(errorDto.badRequest()); + }); + }); + + describe('GET /timeline/bucket', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/timeline/bucket').query({ + size: TimeBucketSize.Month, + timeBucket: '1900-01-01T00:00:00.000Z', + }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should handle 5 digit years', async () => { + const { status, body } = await request(app) + .get('/timeline/bucket') + .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + + // TODO enable date string validation while still accepting 5 digit years + // it('should fail if time bucket is invalid', async () => { + // const { status, body } = await request(app) + // .get('/timeline/bucket') + // .set('Authorization', `Bearer ${user.accessToken}`) + // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest); + // }); + + it('should return time bucket', async () => { + const { status, body } = await request(app) + .get('/timeline/bucket') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + }); +}); diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ddebdaf77d..9ec77670fb 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -169,6 +169,7 @@ doc/TagTypeEnum.md doc/ThumbnailFormat.md doc/TimeBucketResponseDto.md doc/TimeBucketSize.md +doc/TimelineApi.md doc/ToneMapping.md doc/TranscodeHWAccel.md doc/TranscodePolicy.md @@ -211,6 +212,7 @@ lib/api/server_info_api.dart lib/api/shared_link_api.dart lib/api/system_config_api.dart lib/api/tag_api.dart +lib/api/timeline_api.dart lib/api/trash_api.dart lib/api/user_api.dart lib/api_client.dart @@ -556,6 +558,7 @@ test/tag_type_enum_test.dart test/thumbnail_format_test.dart test/time_bucket_response_dto_test.dart test/time_bucket_size_test.dart +test/timeline_api_test.dart test/tone_mapping_test.dart test/transcode_hw_accel_test.dart test/transcode_policy_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index adb7a80fe7..bfdac06c48 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 1aaf195f3a..0778485c37 100644 Binary files a/mobile/openapi/doc/AssetApi.md and b/mobile/openapi/doc/AssetApi.md differ diff --git a/mobile/openapi/doc/TimelineApi.md b/mobile/openapi/doc/TimelineApi.md new file mode 100644 index 0000000000..e98efe7e23 Binary files /dev/null and b/mobile/openapi/doc/TimelineApi.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 5b49d8d67f..2abc20fde8 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index b0395bfcbe..e16ccc73e5 100644 Binary files a/mobile/openapi/lib/api/asset_api.dart and b/mobile/openapi/lib/api/asset_api.dart differ diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart new file mode 100644 index 0000000000..0813f3e00c Binary files /dev/null and b/mobile/openapi/lib/api/timeline_api.dart differ diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index d210d0e4d9..41d0ac8f5c 100644 Binary files a/mobile/openapi/test/asset_api_test.dart and b/mobile/openapi/test/asset_api_test.dart differ diff --git a/mobile/openapi/test/timeline_api_test.dart b/mobile/openapi/test/timeline_api_test.dart new file mode 100644 index 0000000000..ae217b2e40 Binary files /dev/null and b/mobile/openapi/test/timeline_api_test.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3bd92a9d95..0bd6524db4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1720,286 +1720,6 @@ ] } }, - "/asset/time-bucket": { - "get": { - "operationId": "getTimeBucket", - "parameters": [ - { - "name": "albumId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isTrashed", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/AssetOrder" - } - }, - { - "name": "personId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, - { - "name": "timeBucket", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "userId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "withPartners", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withStacked", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - }, - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, - "/asset/time-buckets": { - "get": { - "operationId": "getTimeBuckets", - "parameters": [ - { - "name": "albumId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isTrashed", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/AssetOrder" - } - }, - { - "name": "personId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, - { - "name": "userId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "withPartners", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withStacked", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/TimeBucketResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - }, - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, "/asset/upload": { "post": { "operationId": "uploadFile", @@ -6048,6 +5768,286 @@ ] } }, + "/timeline/bucket": { + "get": { + "operationId": "getTimeBucket", + "parameters": [ + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "personId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "size", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/TimeBucketSize" + } + }, + { + "name": "timeBucket", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "withPartners", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Timeline" + ] + } + }, + "/timeline/buckets": { + "get": { + "operationId": "getTimeBuckets", + "parameters": [ + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "personId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "size", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/TimeBucketSize" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "withPartners", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TimeBucketResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Timeline" + ] + } + }, "/trash/empty": { "post": { "operationId": "emptyTrash", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 4164a3d1c9..00794f1208 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -284,10 +284,6 @@ export type AssetStatsResponseDto = { total: number; videos: number; }; -export type TimeBucketResponseDto = { - count: number; - timeBucket: string; -}; export type CreateAssetDto = { assetData: Blob; deviceAssetId: string; @@ -971,6 +967,10 @@ export type CreateTagDto = { export type UpdateTagDto = { name?: string; }; +export type TimeBucketResponseDto = { + count: number; + timeBucket: string; +}; export type CreateUserDto = { email: string; memoriesEnabled?: boolean; @@ -1456,72 +1456,6 @@ export function getAssetThumbnail({ format, id, key }: { ...opts })); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { - albumId?: string; - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - key?: string; - order?: AssetOrder; - personId?: string; - size: TimeBucketSize; - timeBucket: string; - userId?: string; - withPartners?: boolean; - withStacked?: boolean; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetResponseDto[]; - }>(`/asset/time-bucket${QS.query(QS.explode({ - albumId, - isArchived, - isFavorite, - isTrashed, - key, - order, - personId, - size, - timeBucket, - userId, - withPartners, - withStacked - }))}`, { - ...opts - })); -} -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { - albumId?: string; - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - key?: string; - order?: AssetOrder; - personId?: string; - size: TimeBucketSize; - userId?: string; - withPartners?: boolean; - withStacked?: boolean; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: TimeBucketResponseDto[]; - }>(`/asset/time-buckets${QS.query(QS.explode({ - albumId, - isArchived, - isFavorite, - isTrashed, - key, - order, - personId, - size, - userId, - withPartners, - withStacked - }))}`, { - ...opts - })); -} export function uploadFile({ key, createAssetDto }: { key?: string; createAssetDto: CreateAssetDto; @@ -2595,6 +2529,72 @@ export function tagAssets({ id, assetIdsDto }: { body: assetIdsDto }))); } +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { + albumId?: string; + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + key?: string; + order?: AssetOrder; + personId?: string; + size: TimeBucketSize; + timeBucket: string; + userId?: string; + withPartners?: boolean; + withStacked?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/timeline/bucket${QS.query(QS.explode({ + albumId, + isArchived, + isFavorite, + isTrashed, + key, + order, + personId, + size, + timeBucket, + userId, + withPartners, + withStacked + }))}`, { + ...opts + })); +} +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { + albumId?: string; + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + key?: string; + order?: AssetOrder; + personId?: string; + size: TimeBucketSize; + userId?: string; + withPartners?: boolean; + withStacked?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TimeBucketResponseDto[]; + }>(`/timeline/buckets${QS.query(QS.explode({ + albumId, + isArchived, + isFavorite, + isTrashed, + key, + order, + personId, + size, + userId, + withPartners, + withStacked + }))}`, { + ...opts + })); +} export function emptyTrash(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/trash/empty", { ...opts, @@ -2789,10 +2789,6 @@ export enum ThumbnailFormat { Jpeg = "JPEG", Webp = "WEBP" } -export enum TimeBucketSize { - Day = "DAY", - Month = "MONTH" -} export enum EntityType { Asset = "ASSET", Album = "ALBUM" @@ -2911,3 +2907,7 @@ export enum MapTheme { Light = "light", Dark = "dark" } +export enum TimeBucketSize { + Day = "DAY", + Month = "MONTH" +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index aedd6adf1f..f7aa5f5949 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -31,6 +31,7 @@ import { ServerInfoController } from 'src/controllers/server-info.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; import { TagController } from 'src/controllers/tag.controller'; +import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; import { UserController } from 'src/controllers/user.controller'; import { databaseConfig } from 'src/database.config'; @@ -121,6 +122,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SystemConfigService } from 'src/services/system-config.service'; import { TagService } from 'src/services/tag.service'; +import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; import { UserService } from 'src/services/user.service'; import { otelConfig } from 'src/utils/instrumentation'; @@ -157,6 +159,7 @@ const controllers = [ SharedLinkController, SystemConfigController, TagController, + TimelineController, TrashController, UserController, PersonController, @@ -189,6 +192,7 @@ const services: Provider[] = [ StorageTemplateService, SystemConfigService, TagService, + TimelineService, TrashService, UserService, ]; diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 37e1691137..8e446d23f9 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -14,7 +14,6 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, MetadataSearchDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; @@ -71,18 +70,6 @@ export class AssetController { return this.service.getStatistics(auth, dto); } - @Authenticated({ isShared: true }) - @Get('time-buckets') - getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { - return this.service.getTimeBuckets(auth, dto); - } - - @Authenticated({ isShared: true }) - @Get('time-bucket') - getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { - return this.service.getTimeBucket(auth, dto) as Promise; - } - @Post('jobs') @HttpCode(HttpStatus.NO_CONTENT) runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise { diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts new file mode 100644 index 0000000000..173c6738de --- /dev/null +++ b/server/src/controllers/timeline.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { TimelineService } from 'src/services/timeline.service'; + +@ApiTags('Timeline') +@Controller('timeline') +@Authenticated() +export class TimelineController { + constructor(private service: TimelineService) {} + + @Authenticated({ isShared: true }) + @Get('buckets') + getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { + return this.service.getTimeBuckets(auth, dto); + } + + @Authenticated({ isShared: true }) + @Get('bucket') + getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { + return this.service.getTimeBucket(auth, dto) as Promise; + } +} diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index d5bce3d1ec..f7e502e693 100644 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,7 +4,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { AssetStats, IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobItem, JobName } from 'src/interfaces/job.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -335,129 +335,6 @@ describe(AssetService.name, () => { }); }); - describe('getTimeBuckets', () => { - it("should return buckets if userId and albumId aren't set", async () => { - assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); - - await expect( - sut.getTimeBuckets(authStub.admin, { - size: TimeBucketSize.DAY, - }), - ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); - expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id] }); - }); - }); - - describe('getTimeBucket', () => { - it('should return the assets for a album time bucket if user has album.read', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - albumId: 'album-id', - }); - }); - - it('should return the assets for a archive time bucket if user has archive.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - userId: authStub.admin.user.id, - }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - userIds: [authStub.admin.user.id], - }); - }); - - it('should return the assets for a library time bucket if user has library.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - userId: authStub.admin.user.id, - }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - userIds: [authStub.admin.user.id], - }); - }); - - it('should throw an error if withParners is true and isArchived true or undefined', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: undefined, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - - it('should throw an error if withParners is true and isFavorite is either true or false', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isFavorite: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isFavorite: false, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - - it('should throw an error if withParners is true and isTrash is true', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isTrashed: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - }); - describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { assetMock.getStatistics.mockResolvedValue(stats); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 17fe147c01..b8a9dec874 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -25,12 +25,11 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryType } from 'src/entities/library.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IAssetDeletionJob, @@ -195,72 +194,6 @@ export class AssetService { })); } - private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { - if (dto.albumId) { - await this.access.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); - } else { - dto.userId = dto.userId || auth.user.id; - } - - if (dto.userId) { - await this.access.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); - if (dto.isArchived !== false) { - await this.access.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); - } - } - - if (dto.withPartners) { - const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; - const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; - const requestedTrash = dto.isTrashed === true; - - if (requestedArchived || requestedFavorite || requestedTrash) { - throw new BadRequestException( - 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', - ); - } - } - } - - async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { - await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - - return this.assetRepository.getTimeBuckets(timeBucketOptions); - } - - async getTimeBucket( - auth: AuthDto, - dto: TimeBucketAssetDto, - ): Promise { - await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); - return !auth.sharedLink || auth.sharedLink?.showExif - ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) - : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); - } - - async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { - const { userId, ...options } = dto; - let userIds: string[] | undefined = undefined; - - if (userId) { - userIds = [userId]; - - if (dto.withPartners) { - const partners = await this.partnerRepository.getAll(auth.user.id); - const partnersIds = partners - .filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline) - .map((partner) => partner.sharedById); - - userIds.push(...partnersIds); - } - } - - return { ...options, userIds }; - } - async getStatistics(auth: AuthDto, dto: AssetStatsDto) { const stats = await this.assetRepository.getStatistics(auth.user.id, dto); return mapStats(stats); diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts new file mode 100644 index 0000000000..c6f058022f --- /dev/null +++ b/server/src/services/timeline.service.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException } from '@nestjs/common'; +import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { TimelineService } from 'src/services/timeline.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; + +describe(TimelineService.name, () => { + let sut: TimelineService; + let accessMock: IAccessRepositoryMock; + let assetMock: jest.Mocked; + let partnerMock: jest.Mocked; + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + assetMock = newAssetRepositoryMock(); + partnerMock = newPartnerRepositoryMock(); + + sut = new TimelineService(accessMock, assetMock, partnerMock); + }); + + describe('getTimeBuckets', () => { + it("should return buckets if userId and albumId aren't set", async () => { + assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + + await expect( + sut.getTimeBuckets(authStub.admin, { + size: TimeBucketSize.DAY, + }), + ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); + expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({ + size: TimeBucketSize.DAY, + userIds: [authStub.admin.user.id], + }); + }); + }); + + describe('getTimeBucket', () => { + it('should return the assets for a album time bucket if user has album.read', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + albumId: 'album-id', + }); + }); + + it('should return the assets for a archive time bucket if user has archive.read', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userId: authStub.admin.user.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userIds: [authStub.admin.user.id], + }); + }); + + it('should return the assets for a library time bucket if user has library.read', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userId: authStub.admin.user.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }); + }); + + it('should throw an error if withParners is true and isArchived true or undefined', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: undefined, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw an error if withParners is true and isFavorite is either true or false', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isFavorite: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isFavorite: false, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw an error if withParners is true and isTrash is true', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isTrashed: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts new file mode 100644 index 0000000000..95c4081e6a --- /dev/null +++ b/server/src/services/timeline.service.ts @@ -0,0 +1,86 @@ +import { BadRequestException, Inject } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; + +export class TimelineService { + private accessCore: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private repository: IAssetRepository, + @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, + ) { + this.accessCore = AccessCore.create(accessRepository); + } + + async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { + await this.timeBucketChecks(auth, dto); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); + + return this.repository.getTimeBuckets(timeBucketOptions); + } + + async getTimeBucket( + auth: AuthDto, + dto: TimeBucketAssetDto, + ): Promise { + await this.timeBucketChecks(auth, dto); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); + const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions); + return !auth.sharedLink || auth.sharedLink?.showExif + ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) + : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); + } + + private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { + const { userId, ...options } = dto; + let userIds: string[] | undefined = undefined; + + if (userId) { + userIds = [userId]; + + if (dto.withPartners) { + const partners = await this.partnerRepository.getAll(auth.user.id); + const partnersIds = partners + .filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline) + .map((partner) => partner.sharedById); + + userIds.push(...partnersIds); + } + } + + return { ...options, userIds }; + } + + private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { + if (dto.albumId) { + await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); + } else { + dto.userId = dto.userId || auth.user.id; + } + + if (dto.userId) { + await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); + if (dto.isArchived !== false) { + await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); + } + } + + if (dto.withPartners) { + const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; + const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; + const requestedTrash = dto.isTrashed === true; + + if (requestedArchived || requestedFavorite || requestedTrash) { + throw new BadRequestException( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + } + } + } +}