MAI CALL - step 6

This commit is contained in:
Pedro Gomes 2026-05-16 15:45:29 +01:00
parent d5ab1e5463
commit bfc3fa4faf
5 changed files with 135 additions and 0 deletions

View File

@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@repo/db": "workspace:*", "@repo/db": "workspace:*",
"@repo/storage": "workspace:*",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"superjson": "^2.2.2", "superjson": "^2.2.2",

View File

@ -2,11 +2,13 @@ import { router } from '../trpc';
import { pingRouter } from './ping'; import { pingRouter } from './ping';
import { workstationRouter } from './workstation'; import { workstationRouter } from './workstation';
import { userRouter } from './user'; import { userRouter } from './user';
import { storageRouter } from './storage';
export const appRouter = router({ export const appRouter = router({
ping: pingRouter, ping: pingRouter,
workstation: workstationRouter, workstation: workstationRouter,
user: userRouter, user: userRouter,
storage: storageRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

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

3
pnpm-lock.yaml generated
View File

@ -178,6 +178,9 @@ importers:
'@repo/db': '@repo/db':
specifier: workspace:* specifier: workspace:*
version: link:../db version: link:../db
'@repo/storage':
specifier: workspace:*
version: link:../storage
'@trpc/server': '@trpc/server':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.17.0(typescript@5.9.3) version: 11.17.0(typescript@5.9.3)

View File

@ -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);
});