diff --git a/server/api/@types/task.ts b/server/api/@types/task.ts index 0a1f4a7..c5da6c6 100644 --- a/server/api/@types/task.ts +++ b/server/api/@types/task.ts @@ -5,9 +5,10 @@ export type TaskEntity = { label: string; done: boolean; createdTime: number; + image: { url: string; s3Key: string } | undefined; author: { id: UserId; displayName: string | undefined }; }; -export type TaskCreateVal = { label: string }; +export type TaskCreateVal = { label: string; image?: Blob }; export type TaskUpdateVal = { taskId: Maybe; label?: string; done?: boolean }; diff --git a/server/api/health/controller.ts b/server/api/health/controller.ts index 0e8e7af..3c1631b 100644 --- a/server/api/health/controller.ts +++ b/server/api/health/controller.ts @@ -1,4 +1,5 @@ import { prismaClient } from 'service/prismaClient'; +import { s3 } from 'service/s3Client'; import { defineController } from './$relay'; export default defineController(() => ({ @@ -7,6 +8,7 @@ export default defineController(() => ({ body: { server: 'ok', db: await prismaClient.$queryRaw`SELECT CURRENT_TIMESTAMP;`.then(() => 'ok' as const), + storage: await s3.health().then(() => 'ok' as const), }, }), })); diff --git a/server/api/private/tasks/index.ts b/server/api/private/tasks/index.ts index 246816c..b8b63e2 100644 --- a/server/api/private/tasks/index.ts +++ b/server/api/private/tasks/index.ts @@ -11,6 +11,7 @@ export type Methods = DefineMethods<{ }; post: { + reqFormat: FormData; reqBody: TaskCreateVal; resBody: TaskEntity; }; diff --git a/server/domain/task/model/taskEntity.ts b/server/domain/task/model/taskEntity.ts index c12d6c6..ef4cbb7 100644 --- a/server/domain/task/model/taskEntity.ts +++ b/server/domain/task/model/taskEntity.ts @@ -1,4 +1,10 @@ +import type { MultipartFile } from '@fastify/multipart'; import type { DeletableTaskId } from 'api/@types/brandedId'; import type { TaskEntity } from 'api/@types/task'; +import type { S3PutParams } from 'service/s3Client'; + +export type TaskCreateServerVal = { label: string; image?: MultipartFile }; + +export type TaskSaveVal = { task: TaskEntity; s3Params?: S3PutParams }; export type TaskDeleteVal = { deletableId: DeletableTaskId; task: TaskEntity }; diff --git a/server/domain/task/model/taskMethod.ts b/server/domain/task/model/taskMethod.ts index 701573a..06026dc 100644 --- a/server/domain/task/model/taskMethod.ts +++ b/server/domain/task/model/taskMethod.ts @@ -1,24 +1,36 @@ -import type { TaskCreateVal, TaskEntity, TaskUpdateVal } from 'api/@types/task'; +import type { TaskEntity, TaskUpdateVal } from 'api/@types/task'; import type { UserEntity } from 'api/@types/user'; import assert from 'assert'; import { randomUUID } from 'crypto'; import { deletableTaskIdParser, taskIdParser } from 'service/idParsers'; -import type { TaskDeleteVal } from './taskEntity'; +import { s3 } from 'service/s3Client'; +import type { TaskCreateServerVal, TaskDeleteVal, TaskSaveVal } from './taskEntity'; export const taskMethod = { - create: (user: UserEntity, val: TaskCreateVal): TaskEntity => { - return { + create: async (user: UserEntity, val: TaskCreateServerVal): Promise => { + const task: TaskEntity = { id: taskIdParser.parse(randomUUID()), - label: val.label, done: false, + label: val.label, + image: undefined, createdTime: Date.now(), author: { id: user.id, displayName: user.displayName }, }; + + if (val.image === undefined) return { task }; + + const s3Key = `tasks/images/${randomUUID()}.${val.image.filename.split('.').at(-1)}`; + const url = await s3.getSignedUrl(s3Key); + + return { + task: { ...task, image: { s3Key, url } }, + s3Params: { key: s3Key, data: val.image }, + }; }, - update: (user: UserEntity, task: TaskEntity, val: TaskUpdateVal): TaskEntity => { + update: (user: UserEntity, task: TaskEntity, val: TaskUpdateVal): TaskSaveVal => { assert(user.id === task.author.id); - return { ...task, ...val }; + return { task: { ...task, ...val } }; }, delete: (user: UserEntity, task: TaskEntity): TaskDeleteVal => { assert(user.id === task.author.id); diff --git a/server/domain/task/repository/taskCommand.ts b/server/domain/task/repository/taskCommand.ts index 4941bd7..8f4aee0 100644 --- a/server/domain/task/repository/taskCommand.ts +++ b/server/domain/task/repository/taskCommand.ts @@ -1,22 +1,27 @@ import type { Prisma } from '@prisma/client'; -import type { TaskEntity } from 'api/@types/task'; -import type { TaskDeleteVal } from '../model/taskEntity'; +import { s3 } from 'service/s3Client'; +import type { TaskDeleteVal, TaskSaveVal } from '../model/taskEntity'; export const taskCommand = { - save: async (tx: Prisma.TransactionClient, task: TaskEntity): Promise => { + save: async (tx: Prisma.TransactionClient, val: TaskSaveVal): Promise => { + if (val.s3Params !== undefined) await s3.put(val.s3Params); + await tx.task.upsert({ - where: { id: task.id }, - update: { label: task.label, done: task.done }, + where: { id: val.task.id }, + update: { label: val.task.label, done: val.task.done, imageKey: val.task.image?.s3Key }, create: { - id: task.id, - label: task.label, - done: task.done, - createdAt: new Date(task.createdTime), - authorId: task.author.id, + id: val.task.id, + label: val.task.label, + done: val.task.done, + imageKey: val.task.image?.s3Key, + createdAt: new Date(val.task.createdTime), + authorId: val.task.author.id, }, }); }, delete: async (tx: Prisma.TransactionClient, val: TaskDeleteVal): Promise => { await tx.task.delete({ where: { id: val.deletableId } }); + + if (val.task.image !== undefined) await s3.delete(val.task.image.s3Key); }, }; diff --git a/server/domain/task/repository/taskQuery.ts b/server/domain/task/repository/taskQuery.ts index f0e7ce5..83afcbf 100644 --- a/server/domain/task/repository/taskQuery.ts +++ b/server/domain/task/repository/taskQuery.ts @@ -2,12 +2,17 @@ import type { Prisma, Task, User } from '@prisma/client'; import type { Maybe, TaskId, UserId } from 'api/@types/brandedId'; import type { TaskEntity } from 'api/@types/task'; import { taskIdParser, userIdParser } from 'service/idParsers'; +import { s3 } from 'service/s3Client'; import { depend } from 'velona'; -const toModel = (prismaTask: Task & { Author: User }): TaskEntity => ({ +const toModel = async (prismaTask: Task & { Author: User }): Promise => ({ id: taskIdParser.parse(prismaTask.id), label: prismaTask.label, done: prismaTask.done, + image: + prismaTask.imageKey === null + ? undefined + : { url: await s3.getSignedUrl(prismaTask.imageKey), s3Key: prismaTask.imageKey }, author: { id: userIdParser.parse(prismaTask.authorId), displayName: prismaTask.Author.displayName ?? undefined, @@ -27,7 +32,7 @@ const findManyByAuthorId = async ( include: { Author: true }, }); - return prismaTasks.map(toModel); + return Promise.all(prismaTasks.map(toModel)); }; export const taskQuery = { diff --git a/server/domain/task/service/taskValidator.ts b/server/domain/task/service/taskValidator.ts index 1b2a939..33bcb48 100644 --- a/server/domain/task/service/taskValidator.ts +++ b/server/domain/task/service/taskValidator.ts @@ -1,9 +1,14 @@ -import type { TaskCreateVal, TaskUpdateVal } from 'api/@types/task'; +import { multipartFileValidator } from 'api/$relay'; +import type { TaskUpdateVal } from 'api/@types/task'; import { taskIdParser } from 'service/idParsers'; import { z } from 'zod'; +import type { TaskCreateServerVal } from '../model/taskEntity'; export const taskValidator = { - taskCreate: z.object({ label: z.string() }) satisfies z.ZodType, + taskCreate: z.object({ + label: z.string(), + image: multipartFileValidator().optional(), + }) satisfies z.ZodType, taskUpdate: z.object({ taskId: taskIdParser, label: z.string().optional(), diff --git a/server/domain/task/useCase/taskUseCase.ts b/server/domain/task/useCase/taskUseCase.ts index 7ff3bd2..b658cf0 100644 --- a/server/domain/task/useCase/taskUseCase.ts +++ b/server/domain/task/useCase/taskUseCase.ts @@ -1,19 +1,20 @@ import type { Maybe, TaskId } from 'api/@types/brandedId'; -import type { TaskCreateVal, TaskEntity, TaskUpdateVal } from 'api/@types/task'; +import type { TaskEntity, TaskUpdateVal } from 'api/@types/task'; import type { UserEntity } from 'api/@types/user'; import { transaction } from 'service/prismaClient'; +import type { TaskCreateServerVal } from '../model/taskEntity'; import { taskMethod } from '../model/taskMethod'; import { taskCommand } from '../repository/taskCommand'; import { taskQuery } from '../repository/taskQuery'; export const taskUseCase = { - create: (user: UserEntity, val: TaskCreateVal): Promise => + create: (user: UserEntity, val: TaskCreateServerVal): Promise => transaction('RepeatableRead', async (tx) => { - const task = await taskMethod.create(user, val); + const created = await taskMethod.create(user, val); - await taskCommand.save(tx, task); + await taskCommand.save(tx, created); - return task; + return created.task; }), update: (user: UserEntity, val: TaskUpdateVal): Promise => transaction('RepeatableRead', async (tx) => { @@ -22,14 +23,14 @@ export const taskUseCase = { await taskCommand.save(tx, updated); - return updated; + return updated.task; }), delete: (user: UserEntity, taskId: Maybe): Promise => transaction('RepeatableRead', async (tx) => { const task = await taskQuery.findById(tx, taskId); - const deleteVal = taskMethod.delete(user, task); + const deleted = taskMethod.delete(user, task); - await taskCommand.delete(tx, deleteVal); + await taskCommand.delete(tx, deleted); return task; }), diff --git a/server/package-lock.json b/server/package-lock.json index 91184f5..1979cb4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@aws-sdk/client-s3": "^3.569.0", + "@aws-sdk/s3-request-presigner": "^3.572.0", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^9.0.1", "@fastify/helmet": "^11.1.1", @@ -720,6 +721,59 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.572.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.572.0.tgz", + "integrity": "sha512-naeJGRLnobJOy4JK5jPmBtTu7EkZUHgEJUO1QWvMCNp9VAiqbVwf72/vx5neFWZ7Ioe62s7tIrpQfHiNvmQ0oA==", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.572.0", + "@aws-sdk/types": "3.567.0", + "@aws-sdk/util-format-url": "3.567.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/protocol-http": "^3.3.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.572.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.572.0.tgz", + "integrity": "sha512-ygQL1G2hWoJXkUGL/Xr5q9ojXCH8hgt/oKsxJtc5U8ZXw3SRlL6pCVE7+aiD0l8mgIGbW0vrL08Oc/jYWlakdw==", + "dependencies": { + "@aws-sdk/types": "3.567.0", + "@aws-sdk/util-arn-parser": "3.568.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/signature-v4": "^2.3.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-config-provider": "^2.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.572.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.572.0.tgz", + "integrity": "sha512-FD6FIi8py1ZAR53NjD2VXKDvvQUhhZu7CDUfC9gjAa7JDtv+rJvM9ZuoiQjaDnzzqYxTr4pKqqjLsd6+8BCSWA==", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.572.0", + "@aws-sdk/types": "3.567.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/signature-v4": "^2.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.569.0", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.569.0.tgz", @@ -791,6 +845,20 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.567.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.567.0.tgz", + "integrity": "sha512-zqfuUrSFVYoT02mWnHaP0I7TRjjS3ZE8GhAVyHRUvVOv/O2dFWopFI9jFtMsT21vns7c9yQ1ACH/Kcn3s9t2EQ==", + "dependencies": { + "@aws-sdk/types": "3.567.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.568.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", @@ -8671,6 +8739,52 @@ "tslib": "^2.6.2" } }, + "@aws-sdk/s3-request-presigner": { + "version": "3.572.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.572.0.tgz", + "integrity": "sha512-naeJGRLnobJOy4JK5jPmBtTu7EkZUHgEJUO1QWvMCNp9VAiqbVwf72/vx5neFWZ7Ioe62s7tIrpQfHiNvmQ0oA==", + "requires": { + "@aws-sdk/signature-v4-multi-region": "3.572.0", + "@aws-sdk/types": "3.567.0", + "@aws-sdk/util-format-url": "3.567.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/protocol-http": "^3.3.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + }, + "dependencies": { + "@aws-sdk/middleware-sdk-s3": { + "version": "3.572.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.572.0.tgz", + "integrity": "sha512-ygQL1G2hWoJXkUGL/Xr5q9ojXCH8hgt/oKsxJtc5U8ZXw3SRlL6pCVE7+aiD0l8mgIGbW0vrL08Oc/jYWlakdw==", + "requires": { + "@aws-sdk/types": "3.567.0", + "@aws-sdk/util-arn-parser": "3.568.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/signature-v4": "^2.3.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-config-provider": "^2.3.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/signature-v4-multi-region": { + "version": "3.572.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.572.0.tgz", + "integrity": "sha512-FD6FIi8py1ZAR53NjD2VXKDvvQUhhZu7CDUfC9gjAa7JDtv+rJvM9ZuoiQjaDnzzqYxTr4pKqqjLsd6+8BCSWA==", + "requires": { + "@aws-sdk/middleware-sdk-s3": "3.572.0", + "@aws-sdk/types": "3.567.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/signature-v4": "^2.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + } + } + } + }, "@aws-sdk/signature-v4-multi-region": { "version": "3.569.0", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.569.0.tgz", @@ -8724,6 +8838,17 @@ "tslib": "^2.6.2" } }, + "@aws-sdk/util-format-url": { + "version": "3.567.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.567.0.tgz", + "integrity": "sha512-zqfuUrSFVYoT02mWnHaP0I7TRjjS3ZE8GhAVyHRUvVOv/O2dFWopFI9jFtMsT21vns7c9yQ1ACH/Kcn3s9t2EQ==", + "requires": { + "@aws-sdk/types": "3.567.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" + } + }, "@aws-sdk/util-locate-window": { "version": "3.568.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", diff --git a/server/package.json b/server/package.json index 2d05cde..bcaaa14 100644 --- a/server/package.json +++ b/server/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.569.0", + "@aws-sdk/s3-request-presigner": "^3.572.0", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^9.0.1", "@fastify/helmet": "^11.1.1", diff --git a/server/prisma/migrations/20240510165220_/migration.sql b/server/prisma/migrations/20240510165220_/migration.sql new file mode 100644 index 0000000..301fb76 --- /dev/null +++ b/server/prisma/migrations/20240510165220_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "imageKey" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5462f87..da2c252 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -20,6 +20,7 @@ model Task { id String @id label String done Boolean + imageKey String? createdAt DateTime Author User @relation(fields: [authorId], references: [id]) authorId String diff --git a/server/service/s3Client.ts b/server/service/s3Client.ts index d568fef..e046fe4 100644 --- a/server/service/s3Client.ts +++ b/server/service/s3Client.ts @@ -1,9 +1,51 @@ -import { S3Client } from '@aws-sdk/client-s3'; -import { S3_ACCESS_KEY, S3_ENDPOINT, S3_REGION, S3_SECRET_KEY } from './envValues'; +import { + DeleteObjectCommand, + GetObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { MultipartFile } from '@fastify/multipart'; +import { S3_ACCESS_KEY, S3_BUCKET, S3_ENDPOINT, S3_REGION, S3_SECRET_KEY } from './envValues'; -export const s3Client = new S3Client({ - endpoint: S3_ENDPOINT, - region: S3_REGION, +export type S3PutParams = { key: string; data: MultipartFile }; + +const s3Client = new S3Client({ forcePathStyle: true, - credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY }, + ...(S3_ACCESS_KEY && S3_ENDPOINT && S3_SECRET_KEY + ? { + endpoint: S3_ENDPOINT, + region: S3_REGION, + credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY }, + } + : {}), }); + +export const s3 = { + getSignedUrl: async (key: string): Promise => { + const command = new GetObjectCommand({ Bucket: S3_BUCKET, Key: key }); + + return await getSignedUrl(s3Client, command, { expiresIn: 24 * 60 * 60 }); + }, + health: async (): Promise => { + const command = new ListObjectsV2Command({ Bucket: S3_BUCKET }); + + return await s3Client.send(command).then(() => true); + }, + put: async (params: S3PutParams): Promise => { + const command = new PutObjectCommand({ + Bucket: S3_BUCKET, + ContentType: params.data.mimetype, + Key: params.key, + Body: await params.data.toBuffer(), + }); + + await s3Client.send(command); + }, + delete: async (key: string): Promise => { + const command = new DeleteObjectCommand({ Bucket: S3_BUCKET, Key: key }); + + await s3Client.send(command); + }, +}; diff --git a/server/tests/api/private/di.test.ts b/server/tests/api/private/di.test.ts index db5106a..d5ed825 100644 --- a/server/tests/api/private/di.test.ts +++ b/server/tests/api/private/di.test.ts @@ -22,6 +22,7 @@ test('Dependency Injection', async () => { id: taskIdParser.parse('foo'), label: 'baz', done: false, + image: undefined, createdTime: Date.now(), author: { id: authorId, displayName: undefined }, }, diff --git a/server/tests/api/private/tasks.test.ts b/server/tests/api/private/tasks.test.ts index 3c48095..8eefbcc 100644 --- a/server/tests/api/private/tasks.test.ts +++ b/server/tests/api/private/tasks.test.ts @@ -40,4 +40,9 @@ test(DELETE(apiClient.private.tasks._taskId('_taskId')), async () => { const res = await apiClient.private.tasks._taskId(task.id).delete(); expect(res.status).toEqual(204); + + const task2 = await apiClient.private.tasks.$post({ body: { label: 'b', image: new Blob([]) } }); + const res2 = await apiClient.private.tasks._taskId(task2.id).delete(); + + expect(res2.status === 204).toBeTruthy(); });