diff --git a/packages/api/package.json b/packages/api/package.json index 1455121..785bbcb 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@repo/db": "workspace:*", + "@repo/storage": "workspace:*", "@trpc/server": "^11.0.0", "pino": "^9.5.0", "superjson": "^2.2.2", diff --git a/packages/api/src/routers/_app.ts b/packages/api/src/routers/_app.ts index 8f03af3..81471b2 100644 --- a/packages/api/src/routers/_app.ts +++ b/packages/api/src/routers/_app.ts @@ -2,11 +2,13 @@ import { router } from '../trpc'; import { pingRouter } from './ping'; import { workstationRouter } from './workstation'; import { userRouter } from './user'; +import { storageRouter } from './storage'; export const appRouter = router({ ping: pingRouter, workstation: workstationRouter, user: userRouter, + storage: storageRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/storage.ts b/packages/api/src/routers/storage.ts new file mode 100644 index 0000000..038c52c --- /dev/null +++ b/packages/api/src/routers/storage.ts @@ -0,0 +1,45 @@ +import { randomUUID } from 'node:crypto'; +import { makeStorage } from '@repo/storage'; +import { z } from 'zod'; +import { protectedProcedure, router } from '../trpc'; + +const CONTENT_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const; +const EXT_MAP: Record<(typeof CONTENT_TYPES)[number], string> = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +}; + +const photoKeySchema = z + .string() + .regex(/^tenants\/[a-z0-9-]+\/maintenance\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/); + +export const storageRouter = router({ + signPhotoUpload: protectedProcedure + .input( + z.object({ + contentType: z.enum(CONTENT_TYPES), + byteSize: z.number().int().min(1).max(10 * 1024 * 1024), + }), + ) + .mutation(async ({ ctx, input }) => { + const ext = EXT_MAP[input.contentType]; + const photoKey = `tenants/${ctx.tenantId}/maintenance/${randomUUID()}.${ext}`; + const storage = makeStorage(); + const { url: uploadUrl, expiresAt } = await storage.signPut( + photoKey, + input.contentType, + input.byteSize, + 300, // 5 min + ); + return { uploadUrl, photoKey, expiresAt }; + }), + + signPhotoDownload: protectedProcedure + .input(z.object({ photoKey: photoKeySchema })) + .query(async ({ input }) => { + const storage = makeStorage(); + const { url, expiresAt } = await storage.signGet(input.photoKey, 60); // 1 min + return { url, expiresAt }; + }), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0832b3d..6b7d6e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: '@repo/db': specifier: workspace:* version: link:../db + '@repo/storage': + specifier: workspace:* + version: link:../storage '@trpc/server': specifier: ^11.0.0 version: 11.17.0(typescript@5.9.3) diff --git a/scripts/storage-router-smoke.ts b/scripts/storage-router-smoke.ts new file mode 100644 index 0000000..dc4783e --- /dev/null +++ b/scripts/storage-router-smoke.ts @@ -0,0 +1,84 @@ +/** + * AC verification for Passo 6: + * storage.signPhotoUpload returns a valid URL; direct upload to MinIO works. + * storage.signPhotoDownload returns a valid URL; download matches uploaded content. + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as loadEnv } from 'dotenv'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +loadEnv({ path: path.resolve(here, '../.env') }); + +import { prisma } from '../packages/db/src/index.js'; +import { appRouter, createTRPCContext } from '../packages/api/src/index.js'; +import { createCallerFactory } from '../packages/api/src/trpc.js'; + +async function main() { + const adminUser = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } }); + if (!adminUser) throw new Error('Run pnpm db:seed first'); + + const ctx = await createTRPCContext({ + user: { + id: adminUser.id, + email: adminUser.email, + role: adminUser.role as 'ADMIN', + tenantId: adminUser.tenantId, + }, + headers: new Headers(), + }); + + const caller = createCallerFactory(appRouter)(ctx); + const content = 'fake-photo-content-for-router-smoke-test'; + + // signPhotoUpload + console.log('1. Calling storage.signPhotoUpload...'); + const { uploadUrl, photoKey, expiresAt: uploadExpiry } = await caller.storage.signPhotoUpload({ + contentType: 'image/jpeg', + byteSize: content.length, + }); + console.log(` photoKey: ${photoKey}`); + console.log(` uploadUrl expires: ${uploadExpiry.toISOString()}`); + + // Verify key format + if (!/^tenants\/.+\/maintenance\/.+\.jpg$/.test(photoKey)) { + throw new Error(`Unexpected photoKey format: ${photoKey}`); + } + console.log(' key format ✓'); + + // Upload directly to MinIO + console.log('2. Uploading via presigned PUT...'); + const putRes = await fetch(uploadUrl, { + method: 'PUT', + body: content, + headers: { 'Content-Type': 'image/jpeg' }, + }); + if (!putRes.ok) throw new Error(`PUT failed: ${putRes.status} ${await putRes.text()}`); + console.log(' Upload OK ✓'); + + // signPhotoDownload + console.log('3. Calling storage.signPhotoDownload...'); + const { url: downloadUrl, expiresAt: downloadExpiry } = await caller.storage.signPhotoDownload({ + photoKey, + }); + console.log(` downloadUrl expires: ${downloadExpiry.toISOString()}`); + + // Download and verify + console.log('4. Downloading and verifying...'); + const getRes = await fetch(downloadUrl); + if (!getRes.ok) throw new Error(`GET failed: ${getRes.status}`); + const downloaded = await getRes.text(); + if (downloaded !== content) { + throw new Error(`Content mismatch: expected "${content}", got "${downloaded}"`); + } + console.log(' Content matches ✓'); + + await prisma.$disconnect(); + console.log('\nStorage router AC PASSED'); +} + +main().catch(async (err) => { + console.error('Storage router AC FAILED:', err); + await prisma.$disconnect(); + process.exit(1); +});