From 61a8974919c8db08e445481b4eb7cc5c347920de Mon Sep 17 00:00:00 2001 From: SebastianLevano Date: Mon, 25 May 2026 15:51:33 -0500 Subject: [PATCH] =?UTF-8?q?feat(api,web,infra):=20phase=203=20=E2=80=94=20?= =?UTF-8?q?document=20upload=20+=20S3=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vertical slice: register a document, upload bytes straight to S3 via a presigned PUT (never through Lambda), confirm, and list — frontend to DB. Backend: - migration: documents table (org-scoped indexes, status lifecycle CHECK, updated_at trigger). AI/folder/version columns deferred to later phases. - lib/storage/s3.ts: presignUpload (binds Content-Type), presignDownload; bucket from UPLOADS_BUCKET. Adds @aws-sdk/client-s3 + s3-request-presigner. - DocumentsRepo (org-scoped, keyset pagination) + toDocument mapper. - with-active-org middleware: resolves the active org from the X-Org-Id header (membership-checked) with a sole-membership fallback. - handlers documents/{create,complete,list,download}: create returns a row + presigned PUT; complete flips pending_upload→uploaded; list is keyset paginated; download returns a presigned GET. - shared-types documents schemas (PDF/DOCX, ≤10 MB). Frontend (features/documents): - documents.service (sends X-Org-Id), upload.service (create→S3 PUT with progress→complete; client-side validation mirrors the server). - upload-dropzone component + documents page (live progress, table, empty state, status badges). Route /documents + sidebar nav enabled. Infra: api-stack gains the document Lambdas with S3 grantReadWrite + UPLOADS_BUCKET env; CORS allowHeaders += x-org-id; bin passes the bucket. Tests: documents schema spec, withActiveOrg unit tests, documents integration (create→complete→list, double-complete 400, cross-tenant 403). Serialized the integration script (--no-file-parallelism) so the two suites stop deadlocking on shared-DB TRUNCATEs. Verified: api+web+shared-types lint/build/test, integration 38/38 (Docker), web-e2e 5/5 (chromium), infra tsc. --- .../handlers/documents/complete/handler.ts | 47 ++++ .../src/handlers/documents/create/handler.ts | 41 ++++ .../src/handlers/documents/create/usecase.ts | 33 +++ .../documents/documents.integration.spec.ts | 199 +++++++++++++++++ .../handlers/documents/download/handler.ts | 39 ++++ .../src/handlers/documents/list/handler.ts | 35 +++ apps/api/src/lib/storage/s3.ts | 78 +++++++ apps/api/src/middlewares/index.ts | 1 + apps/api/src/middlewares/middlewares.spec.ts | 60 ++++++ apps/api/src/middlewares/with-active-org.ts | 42 ++++ apps/api/src/repositories/documents-repo.ts | 123 +++++++++++ apps/web-e2e/src/documents.spec.ts | 136 ++++++++++++ apps/web/src/app/app.routes.ts | 6 + .../components/upload-dropzone.component.ts | 65 ++++++ .../app/features/documents/documents.page.ts | 203 ++++++++++++++++++ .../features/documents/documents.service.ts | 62 ++++++ .../app/features/documents/upload.service.ts | 135 ++++++++++++ apps/web/src/app/shared/layouts/app-layout.ts | 2 +- infra/bin/clouddocs.ts | 3 +- infra/lib/stacks/api-stack.ts | 82 ++++++- libs/shared-types/src/index.ts | 1 + .../src/schemas/documents.spec.ts | 44 ++++ libs/shared-types/src/schemas/documents.ts | 92 ++++++++ package.json | 4 +- pnpm-lock.yaml | 147 +++++++++++++ tools/migrations/1779740244699_documents.sql | 39 ++++ 26 files changed, 1713 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/handlers/documents/complete/handler.ts create mode 100644 apps/api/src/handlers/documents/create/handler.ts create mode 100644 apps/api/src/handlers/documents/create/usecase.ts create mode 100644 apps/api/src/handlers/documents/documents.integration.spec.ts create mode 100644 apps/api/src/handlers/documents/download/handler.ts create mode 100644 apps/api/src/handlers/documents/list/handler.ts create mode 100644 apps/api/src/lib/storage/s3.ts create mode 100644 apps/api/src/middlewares/with-active-org.ts create mode 100644 apps/api/src/repositories/documents-repo.ts create mode 100644 apps/web-e2e/src/documents.spec.ts create mode 100644 apps/web/src/app/features/documents/components/upload-dropzone.component.ts create mode 100644 apps/web/src/app/features/documents/documents.page.ts create mode 100644 apps/web/src/app/features/documents/documents.service.ts create mode 100644 apps/web/src/app/features/documents/upload.service.ts create mode 100644 libs/shared-types/src/schemas/documents.spec.ts create mode 100644 libs/shared-types/src/schemas/documents.ts create mode 100644 tools/migrations/1779740244699_documents.sql diff --git a/apps/api/src/handlers/documents/complete/handler.ts b/apps/api/src/handlers/documents/complete/handler.ts new file mode 100644 index 0000000..b193a1f --- /dev/null +++ b/apps/api/src/handlers/documents/complete/handler.ts @@ -0,0 +1,47 @@ +import type { Document } from '@clouddocs/shared-types'; + +import { NotFoundError, ValidationError } from '../../../lib/errors'; +import { DocumentsRepo, toDocument } from '../../../repositories/documents-repo'; +import { + compose, + jsonResponse, + withActiveOrg, + withAuth, + withErrorHandler, + withRequestLogger, + withSecrets, + type LambdaHandler, +} from '../../../middlewares'; + +/** + * POST /v1/documents/{id}/complete — the browser calls this after a successful + * S3 PUT to flip the document from `pending_upload` to `uploaded`. (The async + * extraction pipeline that takes over from here arrives in Phase 4.) + * + * Only a `pending_upload` document can be completed; anything else is a no-op + * client error so we don't silently reset a later lifecycle state. + */ +export const handler: LambdaHandler = withSecrets( + withRequestLogger( + compose(withErrorHandler)( + withAuth( + withActiveOrg(async (ctx) => { + const id = ctx.event.pathParameters?.['id']; + if (!id) throw new ValidationError('Document id missing from path.'); + + const repo = new DocumentsRepo(ctx.orgId); + const existing = await repo.findById(id); + if (!existing) throw new NotFoundError('Document not found.'); + if (existing.status !== 'pending_upload') { + throw new ValidationError(`Document is already '${existing.status}'.`); + } + + const updated = await repo.setStatus(id, 'uploaded'); + if (!updated) throw new NotFoundError('Document not found.'); + const body: Document = toDocument(updated); + return jsonResponse(200, body); + }), + ), + ), + ), +); diff --git a/apps/api/src/handlers/documents/create/handler.ts b/apps/api/src/handlers/documents/create/handler.ts new file mode 100644 index 0000000..494682a --- /dev/null +++ b/apps/api/src/handlers/documents/create/handler.ts @@ -0,0 +1,41 @@ +import { CreateDocumentDtoSchema } from '@clouddocs/shared-types'; + +import { + compose, + jsonResponse, + withActiveOrg, + withAuth, + withErrorHandler, + withJsonBody, + withRequestLogger, + withSecrets, + withValidation, + type LambdaHandler, +} from '../../../middlewares'; +import { createDocumentUseCase } from './usecase'; + +/** + * POST /v1/documents — register a document and return a presigned PUT URL. + * Auth → active org (membership-checked) → validated body. + */ +export const handler: LambdaHandler = withSecrets( + withRequestLogger( + compose( + withErrorHandler, + withJsonBody, + )( + withAuth( + withActiveOrg( + withValidation(CreateDocumentDtoSchema, async (ctx) => { + // user + orgId are guaranteed by withAuth + withActiveOrg upstream. + const user = ctx.user; + const orgId = ctx.orgId; + if (!user || !orgId) throw new Error('Auth/org context missing — middleware bug.'); + const result = await createDocumentUseCase(orgId, user.id, ctx.body); + return jsonResponse(201, result); + }), + ), + ), + ), + ), +); diff --git a/apps/api/src/handlers/documents/create/usecase.ts b/apps/api/src/handlers/documents/create/usecase.ts new file mode 100644 index 0000000..0fe4d2e --- /dev/null +++ b/apps/api/src/handlers/documents/create/usecase.ts @@ -0,0 +1,33 @@ +/** + * Create-document use case: register a `pending_upload` row and hand back a + * presigned PUT URL the browser uploads the bytes to directly. The document id + * is generated here so it can be embedded in the S3 key before the row exists. + */ +import { randomUUID } from 'node:crypto'; + +import type { CreateDocumentDto, CreateDocumentResponse } from '@clouddocs/shared-types'; + +import { DocumentsRepo, toDocument } from '../../../repositories/documents-repo'; +import { buildRawKey, presignUpload } from '../../../lib/storage/s3'; + +export async function createDocumentUseCase( + orgId: string, + userId: string, + dto: CreateDocumentDto, +): Promise { + const id = randomUUID(); + const s3Key = buildRawKey(orgId, id, dto.filename); + + const repo = new DocumentsRepo(orgId); + const row = await repo.create({ + id, + uploadedBy: userId, + filename: dto.filename, + mimeType: dto.mimeType, + sizeBytes: dto.sizeBytes, + s3Key, + }); + + const upload = await presignUpload(s3Key, dto.mimeType); + return { document: toDocument(row), upload }; +} diff --git a/apps/api/src/handlers/documents/documents.integration.spec.ts b/apps/api/src/handlers/documents/documents.integration.spec.ts new file mode 100644 index 0000000..fa3b0ac --- /dev/null +++ b/apps/api/src/handlers/documents/documents.integration.spec.ts @@ -0,0 +1,199 @@ +/** + * End-to-end document flow against a real Postgres (create → complete → list). + * + * Same safety gate as the auth suite: only runs when `RUN_INTEGRATION=1` AND + * the DB URL points at localhost, so `pnpm nx test api` never truncates a real + * database. S3 is mocked (the presign helpers) so the suite needs no AWS creds + * and never touches a bucket — we're exercising the DB + handler wiring, not S3. + * + * To run: `docker compose up -d && pnpm test:integration`. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { APIGatewayProxyEventV2, Context } from 'aws-lambda'; + +const RUN = process.env['RUN_INTEGRATION'] === '1'; +const INTEGRATION_URL = + process.env['INTEGRATION_DATABASE_URL'] ?? + 'postgres://clouddocs:clouddocs@localhost:5434/clouddocs'; +const LOCAL_HOST_RE = /(?:^|@|\/\/)(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::|\/|$)/; +const URL_LOCAL = LOCAL_HOST_RE.test(INTEGRATION_URL); + +if (RUN && URL_LOCAL) { + process.env['DATABASE_URL'] = INTEGRATION_URL; + process.env['UPLOADS_BUCKET'] = 'test-bucket'; +} + +// Mock S3 so presigning is offline and deterministic — no AWS creds needed. +vi.mock('../../lib/storage/s3', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + presignUpload: vi.fn(async (key: string, contentType: string) => ({ + url: `https://s3.test/${key}?signed=put`, + headers: { 'Content-Type': contentType }, + expiresInSeconds: 300, + })), + presignDownload: vi.fn(async (key: string) => ({ + url: `https://s3.test/${key}?signed=get`, + expiresInSeconds: 900, + })), + }; +}); + +import { closePool, query } from '../../lib/db/client'; +import { signAccessToken } from '../../lib/auth/jwt'; +import { handler as createHandler } from './create/handler'; +import { handler as completeHandler } from './complete/handler'; +import { handler as listHandler } from './list/handler'; + +const lambdaCtx = { awsRequestId: 'test-correlation-id' } as Context; + +function makeEvent(overrides: Partial = {}): APIGatewayProxyEventV2 { + return { + routeKey: 'POST /v1/test', + rawPath: '/v1/documents', + requestContext: { + http: { + method: 'POST', + path: '/v1/documents', + protocol: 'HTTP/2', + sourceIp: '127.0.0.1', + userAgent: 'vitest', + }, + }, + headers: { 'user-agent': 'vitest' }, + isBase64Encoded: false, + ...overrides, + } as APIGatewayProxyEventV2; +} + +async function seedOrgUser(): Promise<{ orgId: string; userId: string; token: string }> { + const orgs = await query<{ id: string }>( + `INSERT INTO organizations (name, slug) VALUES ('Acme', 'acme') RETURNING id`, + ); + const orgId = orgs[0]!.id; + const users = await query<{ id: string }>( + `INSERT INTO users (email, password_hash) VALUES ('demo@test.com', 'x') RETURNING id`, + ); + const userId = users[0]!.id; + await query(`INSERT INTO memberships (user_id, org_id, role) VALUES ($1, $2, 'owner')`, [ + userId, + orgId, + ]); + const access = await signAccessToken({ + userId, + email: 'demo@test.com', + memberships: [{ orgId, role: 'owner' }], + }); + return { orgId, userId, token: access.token }; +} + +describe.skipIf(!RUN || !URL_LOCAL)('documents integration flow', () => { + beforeAll(() => { + if (!process.env['JWT_PRIVATE_KEY'] || !process.env['JWT_PUBLIC_KEY']) { + throw new Error('JWT_PRIVATE_KEY and JWT_PUBLIC_KEY must be set for integration tests.'); + } + }); + + beforeEach(async () => { + await query( + 'TRUNCATE documents, refresh_tokens, invitations, memberships, organizations, users RESTART IDENTITY CASCADE', + ); + }); + + afterAll(async () => { + await closePool(); + }); + + it('create → complete → list', async () => { + const { orgId, token } = await seedOrgUser(); + const authHeaders = { authorization: `Bearer ${token}`, 'x-org-id': orgId }; + + const createRes = (await createHandler( + makeEvent({ + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + filename: 'contract.pdf', + mimeType: 'application/pdf', + sizeBytes: 2048, + }), + }), + lambdaCtx, + )) as { statusCode: number; body: string }; + expect(createRes.statusCode).toBe(201); + const created = JSON.parse(createRes.body); + expect(created.document.status).toBe('pending_upload'); + expect(created.upload.url).toContain('signed=put'); + const docId = created.document.id; + + const completeRes = (await completeHandler( + makeEvent({ + rawPath: `/v1/documents/${docId}/complete`, + headers: authHeaders, + pathParameters: { id: docId }, + }), + lambdaCtx, + )) as { statusCode: number; body: string }; + expect(completeRes.statusCode).toBe(200); + expect(JSON.parse(completeRes.body).status).toBe('uploaded'); + + const listRes = (await listHandler( + makeEvent({ + routeKey: 'GET /v1/documents', + headers: authHeaders, + }), + lambdaCtx, + )) as { statusCode: number; body: string }; + expect(listRes.statusCode).toBe(200); + const list = JSON.parse(listRes.body); + expect(list.documents).toHaveLength(1); + expect(list.documents[0].id).toBe(docId); + expect(list.documents[0].status).toBe('uploaded'); + expect(list.nextCursor).toBeNull(); + }); + + it('rejects completing a document twice', async () => { + const { orgId, token } = await seedOrgUser(); + const authHeaders = { authorization: `Bearer ${token}`, 'x-org-id': orgId }; + + const createRes = (await createHandler( + makeEvent({ + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + filename: 'a.pdf', + mimeType: 'application/pdf', + sizeBytes: 10, + }), + }), + lambdaCtx, + )) as { body: string }; + const docId = JSON.parse(createRes.body).document.id; + + const complete = () => + completeHandler( + makeEvent({ headers: authHeaders, pathParameters: { id: docId } }), + lambdaCtx, + ) as Promise<{ statusCode: number }>; + + expect((await complete()).statusCode).toBe(200); + expect((await complete()).statusCode).toBe(400); + }); + + it('forbids acting on an org the user does not belong to', async () => { + const { token } = await seedOrgUser(); + const otherOrg = '99999999-9999-4999-8999-999999999999'; + + const res = (await createHandler( + makeEvent({ + headers: { + authorization: `Bearer ${token}`, + 'x-org-id': otherOrg, + 'content-type': 'application/json', + }, + body: JSON.stringify({ filename: 'a.pdf', mimeType: 'application/pdf', sizeBytes: 10 }), + }), + lambdaCtx, + )) as { statusCode: number }; + expect(res.statusCode).toBe(403); + }); +}); diff --git a/apps/api/src/handlers/documents/download/handler.ts b/apps/api/src/handlers/documents/download/handler.ts new file mode 100644 index 0000000..719e729 --- /dev/null +++ b/apps/api/src/handlers/documents/download/handler.ts @@ -0,0 +1,39 @@ +import type { DownloadResponse } from '@clouddocs/shared-types'; + +import { NotFoundError, ValidationError } from '../../../lib/errors'; +import { DocumentsRepo } from '../../../repositories/documents-repo'; +import { presignDownload } from '../../../lib/storage/s3'; +import { + compose, + jsonResponse, + withActiveOrg, + withAuth, + withErrorHandler, + withRequestLogger, + withSecrets, + type LambdaHandler, +} from '../../../middlewares'; + +/** GET /v1/documents/{id}/download — short-lived presigned GET URL for the object. */ +export const handler: LambdaHandler = withSecrets( + withRequestLogger( + compose(withErrorHandler)( + withAuth( + withActiveOrg(async (ctx) => { + const id = ctx.event.pathParameters?.['id']; + if (!id) throw new ValidationError('Document id missing from path.'); + + const repo = new DocumentsRepo(ctx.orgId); + const doc = await repo.findById(id); + if (!doc) throw new NotFoundError('Document not found.'); + if (doc.status === 'pending_upload') { + throw new ValidationError('Document has not finished uploading.'); + } + + const body: DownloadResponse = await presignDownload(doc.s3_key, doc.filename); + return jsonResponse(200, body); + }), + ), + ), + ), +); diff --git a/apps/api/src/handlers/documents/list/handler.ts b/apps/api/src/handlers/documents/list/handler.ts new file mode 100644 index 0000000..3183f94 --- /dev/null +++ b/apps/api/src/handlers/documents/list/handler.ts @@ -0,0 +1,35 @@ +import { DocumentListQuerySchema, type DocumentListResponse } from '@clouddocs/shared-types'; + +import { ValidationError } from '../../../lib/errors'; +import { DocumentsRepo, toDocument } from '../../../repositories/documents-repo'; +import { + compose, + jsonResponse, + withActiveOrg, + withAuth, + withErrorHandler, + withRequestLogger, + withSecrets, + type LambdaHandler, +} from '../../../middlewares'; + +/** GET /v1/documents — keyset-paginated list, newest first, scoped to the active org. */ +export const handler: LambdaHandler = withSecrets( + withRequestLogger( + compose(withErrorHandler)( + withAuth( + withActiveOrg(async (ctx) => { + const parsed = DocumentListQuerySchema.safeParse(ctx.event.queryStringParameters ?? {}); + if (!parsed.success) { + throw new ValidationError('Invalid query parameters.', parsed.error.issues); + } + + const repo = new DocumentsRepo(ctx.orgId); + const { rows, nextCursor } = await repo.list(parsed.data.limit, parsed.data.cursor); + const body: DocumentListResponse = { documents: rows.map(toDocument), nextCursor }; + return jsonResponse(200, body); + }), + ), + ), + ), +); diff --git a/apps/api/src/lib/storage/s3.ts b/apps/api/src/lib/storage/s3.ts new file mode 100644 index 0000000..47b4ce6 --- /dev/null +++ b/apps/api/src/lib/storage/s3.ts @@ -0,0 +1,78 @@ +/** + * S3 access for document uploads/downloads. + * + * The browser uploads raw bytes directly to S3 via a presigned PUT URL, so the + * file never passes through Lambda (plan §2.2 — the big free-tier/latency win). + * Reads are served the same way with a short-lived presigned GET. + * + * The client is created once per warm container (module scope) so concurrent + * invocations reuse it. + */ +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +/** TTLs kept short — plan §13: 5 min to upload, 15 min to download. */ +export const UPLOAD_URL_TTL_SECONDS = 5 * 60; +export const DOWNLOAD_URL_TTL_SECONDS = 15 * 60; + +let client: S3Client | undefined; + +function s3(): S3Client { + client ??= new S3Client({}); + return client; +} + +function bucketName(): string { + const name = process.env['UPLOADS_BUCKET']; + if (!name) throw new Error('UPLOADS_BUCKET env var is not set.'); + return name; +} + +/** + * Object key for a raw upload. Prefixed per-org and per-document so keys are + * not enumerable across tenants and map 1:1 to a documents row. + */ +export function buildRawKey(orgId: string, documentId: string, filename: string): string { + // Strip any path components a client might smuggle in the filename. + const safeName = filename.replace(/[/\\]/g, '_'); + return `raw-uploads/${orgId}/${documentId}/${safeName}`; +} + +export interface PresignedPut { + url: string; + headers: Record; + expiresInSeconds: number; +} + +/** + * Presigned PUT URL. The Content-Type is bound into the signature, so the + * browser MUST send the same `Content-Type` header on the PUT or S3 rejects it + * — this stops a client from claiming a PDF and uploading something else. + */ +export async function presignUpload(key: string, contentType: string): Promise { + const command = new PutObjectCommand({ + Bucket: bucketName(), + Key: key, + ContentType: contentType, + }); + const url = await getSignedUrl(s3(), command, { expiresIn: UPLOAD_URL_TTL_SECONDS }); + return { + url, + headers: { 'Content-Type': contentType }, + expiresInSeconds: UPLOAD_URL_TTL_SECONDS, + }; +} + +/** Presigned GET URL for downloading a stored object. */ +export async function presignDownload( + key: string, + filename: string, +): Promise<{ url: string; expiresInSeconds: number }> { + const command = new GetObjectCommand({ + Bucket: bucketName(), + Key: key, + ResponseContentDisposition: `attachment; filename="${filename.replace(/"/g, '')}"`, + }); + const url = await getSignedUrl(s3(), command, { expiresIn: DOWNLOAD_URL_TTL_SECONDS }); + return { url, expiresInSeconds: DOWNLOAD_URL_TTL_SECONDS }; +} diff --git a/apps/api/src/middlewares/index.ts b/apps/api/src/middlewares/index.ts index fb7b504..e295e5d 100644 --- a/apps/api/src/middlewares/index.ts +++ b/apps/api/src/middlewares/index.ts @@ -6,5 +6,6 @@ export * from './with-json-body'; export * from './with-validation'; export * from './with-auth'; export * from './with-org-scope'; +export * from './with-active-org'; export * from './with-secrets'; export * from './with-csrf'; diff --git a/apps/api/src/middlewares/middlewares.spec.ts b/apps/api/src/middlewares/middlewares.spec.ts index 9ce41ce..a82c9e3 100644 --- a/apps/api/src/middlewares/middlewares.spec.ts +++ b/apps/api/src/middlewares/middlewares.spec.ts @@ -9,6 +9,8 @@ import { withJsonBody } from './with-json-body'; import { withRequestLogger } from './with-request-logger'; import { withValidation } from './with-validation'; import { withCsrf } from './with-csrf'; +import { withActiveOrg } from './with-active-org'; +import type { AuthenticatedContext } from './with-auth'; function makeEvent(overrides: Partial = {}): APIGatewayProxyEventV2 { return { @@ -165,6 +167,64 @@ describe('withCsrf', () => { }); }); +describe('withActiveOrg', () => { + const ORG_A = '11111111-1111-4111-8111-111111111111'; + const ORG_B = '22222222-2222-4222-8222-222222222222'; + + function ctxWith( + memberships: Array<{ orgId: string; role: 'owner' | 'admin' | 'member' | 'viewer' }>, + headers: Record = {}, + ): AuthenticatedContext { + return { + event: makeEvent({ headers }), + lambdaContext: fakeContext, + correlationId: 'x', + log: { info: () => undefined, error: () => undefined } as any, + user: { id: 'u1', email: 'u@test.com', memberships }, + } as AuthenticatedContext; + } + + const echoOrg = withActiveOrg(async (ctx) => ({ + statusCode: 200, + body: JSON.stringify({ orgId: ctx.orgId, role: ctx.role }), + })); + + it('resolves the org from the X-Org-Id header and checks membership', async () => { + const res: any = await echoOrg( + ctxWith( + [ + { orgId: ORG_A, role: 'owner' }, + { orgId: ORG_B, role: 'viewer' }, + ], + { 'x-org-id': ORG_B }, + ), + ); + expect(JSON.parse(res.body)).toEqual({ orgId: ORG_B, role: 'viewer' }); + }); + + it('falls back to the sole membership when no header is sent', async () => { + const res: any = await echoOrg(ctxWith([{ orgId: ORG_A, role: 'owner' }])); + expect(JSON.parse(res.body).orgId).toBe(ORG_A); + }); + + it('rejects an org the user is not a member of (403)', async () => { + await expect( + echoOrg(ctxWith([{ orgId: ORG_A, role: 'owner' }], { 'x-org-id': ORG_B })), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('400s when multiple memberships and no header (ambiguous)', async () => { + await expect( + echoOrg( + ctxWith([ + { orgId: ORG_A, role: 'owner' }, + { orgId: ORG_B, role: 'member' }, + ]), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + describe('ValidationError instance', () => { it('carries the right statusCode + code', () => { const err = new ValidationError('Bad body.'); diff --git a/apps/api/src/middlewares/with-active-org.ts b/apps/api/src/middlewares/with-active-org.ts new file mode 100644 index 0000000..2cd5f75 --- /dev/null +++ b/apps/api/src/middlewares/with-active-org.ts @@ -0,0 +1,42 @@ +/** + * Resolves the *active* org for routes that aren't nested under `/orgs/:orgId` + * (e.g. `/v1/documents`). Unlike {@link withOrgScope} which reads a path param, + * this reads the `X-Org-Id` request header — the SPA sends its currently + * selected org. Membership is always verified against the access token, so a + * client can't act on an org it doesn't belong to. + * + * Fallback: if the header is absent and the user belongs to exactly one org, we + * use it (covers the common single-org case without forcing the header). With + * multiple memberships and no header we 400, since the target is ambiguous. + */ +import { ForbiddenError, ValidationError } from '../lib/errors'; +import type { AuthenticatedContext, AuthenticatedHandler } from './with-auth'; +import type { OrgScopedContext, OrgScopedHandler } from './with-org-scope'; + +export const ORG_HEADER = 'x-org-id'; + +function readOrgHeader(ctx: AuthenticatedContext): string | undefined { + return ctx.event.headers?.[ORG_HEADER] ?? ctx.event.headers?.['X-Org-Id']; +} + +export function withActiveOrg(handler: OrgScopedHandler): AuthenticatedHandler { + return async (ctx) => { + const requested = readOrgHeader(ctx)?.trim(); + const { memberships } = ctx.user; + + let orgId: string; + if (requested) { + orgId = requested; + } else if (memberships.length === 1 && memberships[0]) { + orgId = memberships[0].orgId; + } else { + throw new ValidationError('Missing X-Org-Id header (multiple organizations).'); + } + + const membership = memberships.find((m) => m.orgId === orgId); + if (!membership) throw new ForbiddenError('You are not a member of this organization.'); + + const scoped: OrgScopedContext = { ...ctx, orgId, role: membership.role }; + return handler(scoped); + }; +} diff --git a/apps/api/src/repositories/documents-repo.ts b/apps/api/src/repositories/documents-repo.ts new file mode 100644 index 0000000..e852444 --- /dev/null +++ b/apps/api/src/repositories/documents-repo.ts @@ -0,0 +1,123 @@ +/** + * Documents repository — org-scoped. Every query is constrained to the org the + * repo was constructed with (see {@link OrgScopedRepository}), so a handler can + * never read or mutate another tenant's documents. + */ +import type { Document, DocumentStatus } from '@clouddocs/shared-types'; + +import type { TxClient } from '../lib/db/client'; +import { OrgScopedRepository } from './org-scoped-repository'; + +// A type alias (not interface) so it satisfies the `Record` +// constraint on OrgScopedRepository.scopedQuery — interfaces can't be assigned +// to an index signature, type aliases of object types can. +export type DocumentRow = { + id: string; + org_id: string; + uploaded_by: string; + filename: string; + mime_type: string; + size_bytes: string; // BIGINT comes back as a string from node-postgres + s3_key: string; + status: DocumentStatus; + error: string | null; + metadata: Record; + created_at: Date; + updated_at: Date; +}; + +export interface CreateDocumentInput { + /** Generated by the caller so the S3 key can embed it before insert. */ + id: string; + uploadedBy: string; + filename: string; + mimeType: string; + sizeBytes: number; + s3Key: string; +} + +export class DocumentsRepo extends OrgScopedRepository { + /** Insert a new `pending_upload` row and return it. */ + async create(input: CreateDocumentInput, tx?: TxClient): Promise { + const rows = await this.scopedQuery( + `INSERT INTO documents (id, org_id, uploaded_by, filename, mime_type, size_bytes, s3_key) + VALUES ($2, $1, $3, $4, $5, $6, $7) + RETURNING *`, + [input.id, input.uploadedBy, input.filename, input.mimeType, input.sizeBytes, input.s3Key], + tx, + ); + const row = rows[0]; + if (!row) throw new Error('Insert into documents returned no row.'); + return row; + } + + async findById(id: string): Promise { + const rows = await this.scopedQuery( + 'SELECT * FROM documents WHERE org_id = $1 AND id = $2', + [id], + ); + return rows[0]; + } + + /** Transition a document to a new status (e.g. `uploaded`, `failed`). */ + async setStatus( + id: string, + status: DocumentStatus, + error?: string, + ): Promise { + const rows = await this.scopedQuery( + `UPDATE documents SET status = $3, error = $4 + WHERE org_id = $1 AND id = $2 + RETURNING *`, + [id, status, error ?? null], + ); + return rows[0]; + } + + /** + * Keyset pagination, newest first. Pass the previous page's last `id` as + * `cursor` to get the next page. Fetches one extra row to know whether more + * exist without a second count query. + */ + async list( + limit: number, + cursor?: string, + ): Promise<{ rows: DocumentRow[]; nextCursor: string | null }> { + const rows = cursor + ? await this.scopedQuery( + `SELECT * FROM documents + WHERE org_id = $1 + AND (created_at, id) < (SELECT created_at, id FROM documents WHERE id = $2 AND org_id = $1) + ORDER BY created_at DESC, id DESC + LIMIT $3`, + [cursor, limit + 1], + ) + : await this.scopedQuery( + `SELECT * FROM documents + WHERE org_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2`, + [limit + 1], + ); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + return { rows: page, nextCursor: hasMore ? (page[page.length - 1]?.id ?? null) : null }; + } +} + +/** Map a DB row to the API/shared-types `Document` shape. */ +export function toDocument(row: DocumentRow): Document { + return { + id: row.id, + orgId: row.org_id, + uploadedBy: row.uploaded_by, + filename: row.filename, + mimeType: row.mime_type, + sizeBytes: Number(row.size_bytes), + status: row.status, + error: row.error, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + }; +} diff --git a/apps/web-e2e/src/documents.spec.ts b/apps/web-e2e/src/documents.spec.ts new file mode 100644 index 0000000..01f1f1b --- /dev/null +++ b/apps/web-e2e/src/documents.spec.ts @@ -0,0 +1,136 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Document upload flow e2e. The API and the S3 PUT are mocked at the network + * layer, so the test is deterministic and touches neither the live backend nor + * a real bucket. It verifies the full browser path: pick file → presigned PUT → + * complete → appears in the list. + */ + +const ORG_ID = '33333333-3333-4333-8333-333333333333'; + +const SESSION = { + user: { + id: '11111111-1111-4111-8111-111111111111', + email: 'ada@example.com', + displayName: 'Ada Lovelace', + avatarUrl: null, + emailVerified: false, + createdAt: '2026-05-25T00:00:00.000Z', + }, + memberships: [ + { + id: '22222222-2222-4222-8222-222222222222', + orgId: ORG_ID, + role: 'owner', + organization: { + id: ORG_ID, + name: 'Analytical Engines', + slug: 'analytical-engines', + plan: 'free', + createdAt: '2026-05-25T00:00:00.000Z', + }, + }, + ], + tokens: { accessToken: 'mock.access.token', accessTokenExpiresAt: '2026-05-25T00:15:00.000Z' }, +}; + +const DOC_ID = '44444444-4444-4444-8444-444444444444'; +const S3_PUT_URL = 'https://s3.example.test/raw-uploads/put-target'; + +function makeDoc(status: 'pending_upload' | 'uploaded') { + return { + id: DOC_ID, + orgId: ORG_ID, + uploadedBy: SESSION.user.id, + filename: 'contract.pdf', + mimeType: 'application/pdf', + sizeBytes: 24, + status, + error: null, + createdAt: '2026-05-25T10:00:00.000Z', + updatedAt: '2026-05-25T10:00:00.000Z', + }; +} + +const json = (body: unknown, status = 200) => ({ + status, + contentType: 'application/json', + body: JSON.stringify(body), +}); + +/** Boot authenticated: APP_INITIALIZER's refresh returns a live session. */ +async function stubAuthenticatedBoot(page: Page): Promise { + await page.route('**/v1/auth/refresh', (route) => route.fulfill(json(SESSION))); +} + +test('upload a document → it appears in the list', async ({ page }) => { + await stubAuthenticatedBoot(page); + + let uploaded = false; + + // GET list + POST create share the /v1/documents path; branch on method. + // `**` after the path so the glob also matches the `?limit=...` query on GET. + await page.route('**/v1/documents**', (route) => { + if (route.request().method() === 'GET') { + return route.fulfill( + json({ documents: uploaded ? [makeDoc('uploaded')] : [], nextCursor: null }), + ); + } + // POST create → pending row + presigned PUT + return route.fulfill( + json( + { + document: makeDoc('pending_upload'), + upload: { + url: S3_PUT_URL, + headers: { 'Content-Type': 'application/pdf' }, + expiresInSeconds: 300, + }, + }, + 201, + ), + ); + }); + + // Registered after the general route → higher priority for these URLs. + await page.route('**/v1/documents/*/complete', (route) => { + uploaded = true; + return route.fulfill(json(makeDoc('uploaded'))); + }); + await page.route(`${S3_PUT_URL}**`, (route) => route.fulfill({ status: 200, body: '' })); + + await page.goto('/documents'); + + // Empty state first. + await expect(page.getByTestId('empty-state')).toBeVisible(); + + // Pick a file via the hidden input. + await page.getByTestId('file-input').setInputFiles({ + name: 'contract.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('%PDF-1.4 fake pdf bytes'), + }); + + // The document shows up in the list once upload + complete + refresh finish. + await expect(page.getByTestId('documents-table')).toBeVisible(); + await expect(page.getByTestId('documents-table')).toContainText('contract.pdf'); + await expect(page.getByTestId('documents-table')).toContainText('uploaded'); +}); + +test('rejects an unsupported file type client-side', async ({ page }) => { + await stubAuthenticatedBoot(page); + await page.route('**/v1/documents**', (route) => + route.fulfill(json({ documents: [], nextCursor: null })), + ); + + await page.goto('/documents'); + await page.getByTestId('file-input').setInputFiles({ + name: 'photo.png', + mimeType: 'image/png', + buffer: Buffer.from('fakepng'), + }); + + await expect(page.getByTestId('rejected')).toContainText('photo.png'); + await expect(page.getByTestId('rejected')).toContainText('PDF and DOCX'); +}); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index aeb6869..1a99c36 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -24,6 +24,12 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./features/dashboard/dashboard.page').then((m) => m.DashboardPage), }, + { + path: 'documents', + title: 'Documents · CloudDocs AI', + loadComponent: () => + import('./features/documents/documents.page').then((m) => m.DocumentsPage), + }, ], }, { diff --git a/apps/web/src/app/features/documents/components/upload-dropzone.component.ts b/apps/web/src/app/features/documents/components/upload-dropzone.component.ts new file mode 100644 index 0000000..1b6e13a --- /dev/null +++ b/apps/web/src/app/features/documents/components/upload-dropzone.component.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component, output, signal } from '@angular/core'; + +/** + * Drag-and-drop / click-to-browse file picker. Emits selected files; all upload + * orchestration + per-file progress lives in the parent page (UploadService). + */ +@Component({ + selector: 'app-upload-dropzone', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, +}) +export class UploadDropzoneComponent { + readonly filesSelected = output(); + protected readonly dragging = signal(false); + + protected onDragOver(event: DragEvent): void { + event.preventDefault(); + this.dragging.set(true); + } + + protected onDragLeave(): void { + this.dragging.set(false); + } + + protected onDrop(event: DragEvent): void { + event.preventDefault(); + this.dragging.set(false); + const files = event.dataTransfer?.files; + if (files?.length) this.filesSelected.emit(Array.from(files)); + } + + protected onInput(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files?.length) this.filesSelected.emit(Array.from(input.files)); + input.value = ''; // allow re-selecting the same file + } +} diff --git a/apps/web/src/app/features/documents/documents.page.ts b/apps/web/src/app/features/documents/documents.page.ts new file mode 100644 index 0000000..b250edc --- /dev/null +++ b/apps/web/src/app/features/documents/documents.page.ts @@ -0,0 +1,203 @@ +import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; + +import type { Document } from '@clouddocs/shared-types'; + +import { apiErrorMessage } from '../../shared/utils/api-error'; +import { DocumentsService } from './documents.service'; +import { UploadDropzoneComponent } from './components/upload-dropzone.component'; +import { UploadService, validateFile, type UploadHandle } from './upload.service'; + +interface RejectedFile { + name: string; + reason: string; +} + +@Component({ + selector: 'app-documents', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UploadDropzoneComponent], + template: ` +
+
+

Documents

+

Upload PDFs and DOCX files to your workspace.

+
+
+ + + + + @if (rejected().length) { +
    + @for (r of rejected(); track r.name) { +
  • + {{ r.name }} — {{ r.reason }} +
  • + } +
+ } + + + @if (uploads().length) { +
+ @for (u of uploads(); track u.file.name + u.file.size) { +
+
+ {{ u.file.name }} + {{ uploadLabel(u) }} +
+
+
+
+ @if (u.error()) { +

{{ u.error() }}

+ } +
+ } +
+ } + + +
+ @if (loading()) { +

Loading…

+ } @else if (loadError()) { +

+ {{ loadError() }} +

+ } @else if (documents().length === 0) { +
+

No documents yet

+

Upload your first file to get started.

+
+ } @else { + + + + + + + + + + + @for (doc of documents(); track doc.id) { + + + + + + + } + +
NameSizeStatusUploaded
{{ doc.filename }}{{ formatSize(doc.sizeBytes) }} + {{ doc.status }} + {{ formatDate(doc.createdAt) }}
+ } +
+ `, +}) +export class DocumentsPage implements OnInit { + private readonly documentsApi = inject(DocumentsService); + private readonly uploadService = inject(UploadService); + + protected readonly documents = signal([]); + protected readonly loading = signal(true); + protected readonly loadError = signal(null); + protected readonly uploads = signal([]); + protected readonly rejected = signal([]); + + ngOnInit(): void { + this.refresh(); + } + + protected onFilesSelected(files: File[]): void { + this.rejected.set([]); + const accepted: File[] = []; + const rejected: RejectedFile[] = []; + + for (const file of files) { + const reason = validateFile(file); + if (reason) rejected.push({ name: file.name, reason }); + else accepted.push(file); + } + this.rejected.set(rejected); + + for (const file of accepted) { + const handle = this.uploadService.upload(file); + this.uploads.update((list) => [handle, ...list]); + void handle.done.then((ok) => { + if (ok) this.refresh(); + }); + } + } + + private refresh(): void { + this.loading.set(true); + this.loadError.set(null); + this.documentsApi.list().subscribe({ + next: (res) => { + this.documents.set(res.documents); + this.loading.set(false); + }, + error: (err) => { + this.loadError.set(apiErrorMessage(err, 'Could not load documents.')); + this.loading.set(false); + }, + }); + } + + protected uploadLabel(u: UploadHandle): string { + switch (u.status()) { + case 'creating': + return 'Preparing…'; + case 'uploading': + return `${u.progress()}%`; + case 'completing': + return 'Finishing…'; + case 'done': + return 'Done'; + case 'error': + return 'Failed'; + } + } + + protected formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + protected formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + + protected statusClass(status: Document['status']): string { + switch (status) { + case 'ready': + return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-400'; + case 'failed': + return 'border-danger/40 bg-danger/10 text-danger'; + case 'pending_upload': + return 'border-border-strong bg-surface-3 text-text-dim'; + default: + return 'border-brand-500/40 bg-brand-500/10 text-brand-300'; + } + } +} diff --git a/apps/web/src/app/features/documents/documents.service.ts b/apps/web/src/app/features/documents/documents.service.ts new file mode 100644 index 0000000..7985ff5 --- /dev/null +++ b/apps/web/src/app/features/documents/documents.service.ts @@ -0,0 +1,62 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import type { + CreateDocumentDto, + CreateDocumentResponse, + Document, + DocumentListResponse, + DownloadResponse, +} from '@clouddocs/shared-types'; + +import { API_BASE_URL } from '../../core/api/api.config'; +import { AuthService } from '../../core/auth/auth.service'; + +/** + * Thin API client for the document endpoints. Every call carries the active + * org via the `X-Org-Id` header (resolved server-side by `withActiveOrg`); the + * auth interceptor adds the bearer token + credentials. + */ +@Injectable({ providedIn: 'root' }) +export class DocumentsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(API_BASE_URL); + private readonly auth = inject(AuthService); + + private get url(): string { + return `${this.baseUrl}/v1/documents`; + } + + /** Header carrying the currently selected org. */ + private orgHeaders(): HttpHeaders { + const orgId = this.auth.activeOrg()?.id; + return orgId ? new HttpHeaders({ 'X-Org-Id': orgId }) : new HttpHeaders(); + } + + /** Register a document, returning the row + presigned PUT to upload to. */ + create(dto: CreateDocumentDto): Observable { + return this.http.post(this.url, dto, { headers: this.orgHeaders() }); + } + + /** Flip a document to `uploaded` after the S3 PUT succeeds. */ + complete(id: string): Observable { + return this.http.post( + `${this.url}/${id}/complete`, + {}, + { headers: this.orgHeaders() }, + ); + } + + list(cursor?: string, limit = 20): Observable { + const params: Record = { limit: String(limit) }; + if (cursor) params['cursor'] = cursor; + return this.http.get(this.url, { headers: this.orgHeaders(), params }); + } + + download(id: string): Observable { + return this.http.get(`${this.url}/${id}/download`, { + headers: this.orgHeaders(), + }); + } +} diff --git a/apps/web/src/app/features/documents/upload.service.ts b/apps/web/src/app/features/documents/upload.service.ts new file mode 100644 index 0000000..08ca8d4 --- /dev/null +++ b/apps/web/src/app/features/documents/upload.service.ts @@ -0,0 +1,135 @@ +import { inject, Injectable, signal, type Signal, type WritableSignal } from '@angular/core'; +import { HttpClient, HttpEventType } from '@angular/common/http'; +import { lastValueFrom } from 'rxjs'; + +import { + ALLOWED_UPLOAD_MIME_TYPES, + MAX_UPLOAD_SIZE_BYTES, + type Document, +} from '@clouddocs/shared-types'; + +import { DocumentsService } from './documents.service'; + +export type UploadStatus = 'creating' | 'uploading' | 'completing' | 'done' | 'error'; + +/** Live state for a single file upload, surfaced as signals for the UI. */ +export interface UploadHandle { + readonly file: File; + readonly progress: Signal; // 0..100, the S3 PUT progress + readonly status: Signal; + readonly error: Signal; + readonly document: Signal; + /** Resolves true on success, false on failure (never rejects). */ + readonly done: Promise; +} + +interface MutableHandle extends UploadHandle { + readonly progress: WritableSignal; + readonly status: WritableSignal; + readonly error: WritableSignal; + readonly document: WritableSignal; +} + +/** Client-side guard mirroring the server's CreateDocumentDto constraints. */ +export function validateFile(file: File): string | null { + if (!(ALLOWED_UPLOAD_MIME_TYPES as readonly string[]).includes(file.type)) { + return 'Only PDF and DOCX files are supported.'; + } + if (file.size > MAX_UPLOAD_SIZE_BYTES) { + return 'File exceeds the 10 MB limit.'; + } + if (file.size === 0) { + return 'File is empty.'; + } + return null; +} + +/** + * Orchestrates the three-step upload: + * 1. POST /documents → presigned PUT URL (+ pending_upload row) + * 2. PUT the bytes straight to S3 (progress reported here; never via Lambda) + * 3. POST /documents/:id/complete → flip to `uploaded` + * + * `upload()` returns immediately with a handle whose signals drive the UI; the + * async work runs in the background and updates them. + */ +@Injectable({ providedIn: 'root' }) +export class UploadService { + private readonly http = inject(HttpClient); + private readonly documents = inject(DocumentsService); + + upload(file: File): UploadHandle { + const handle: MutableHandle = { + file, + progress: signal(0), + status: signal('creating'), + error: signal(null), + document: signal(null), + done: Promise.resolve(false), + }; + + // Replace the placeholder promise with the real run. + (handle as { done: Promise }).done = this.run(file, handle); + return handle; + } + + private async run(file: File, handle: MutableHandle): Promise { + try { + const created = await lastValueFrom( + this.documents.create({ + filename: file.name, + // The browser-reported MIME is validated server-side too. + mimeType: file.type as (typeof ALLOWED_UPLOAD_MIME_TYPES)[number], + sizeBytes: file.size, + }), + ); + + handle.status.set('uploading'); + await this.putToS3(created.upload.url, created.upload.headers, file, handle.progress); + + handle.status.set('completing'); + const doc = await lastValueFrom(this.documents.complete(created.document.id)); + + handle.document.set(doc); + handle.progress.set(100); + handle.status.set('done'); + return true; + } catch (err) { + handle.status.set('error'); + handle.error.set(messageFor(err)); + return false; + } + } + + /** Raw presigned PUT with upload-progress events. Bypasses the auth interceptor (off-origin). */ + private putToS3( + url: string, + headers: Record, + file: File, + progress: WritableSignal, + ): Promise { + const request$ = this.http.put(url, file, { + headers, + reportProgress: true, + observe: 'events', + responseType: 'text', + }); + + return new Promise((resolve, reject) => { + request$.subscribe({ + next: (event) => { + if (event.type === HttpEventType.UploadProgress && event.total) { + progress.set(Math.round((event.loaded / event.total) * 90)); // reserve last 10% for complete + } + }, + error: reject, + complete: resolve, + }); + }); + } +} + +function messageFor(err: unknown): string { + if (err instanceof Error && err.message) return err.message; + return 'Upload failed. Please try again.'; +} diff --git a/apps/web/src/app/shared/layouts/app-layout.ts b/apps/web/src/app/shared/layouts/app-layout.ts index ad957ed..94b95da 100644 --- a/apps/web/src/app/shared/layouts/app-layout.ts +++ b/apps/web/src/app/shared/layouts/app-layout.ts @@ -87,7 +87,7 @@ export class AppLayout { protected readonly nav = [ { icon: '🏠', label: 'Home', link: '/dashboard', disabled: false }, - { icon: '📄', label: 'Documents', link: '/dashboard', disabled: true }, + { icon: '📄', label: 'Documents', link: '/documents', disabled: false }, { icon: '🔍', label: 'Search', link: '/dashboard', disabled: true }, { icon: '💬', label: 'Chat', link: '/dashboard', disabled: true }, { icon: '⚙️', label: 'Settings', link: '/dashboard', disabled: true }, diff --git a/infra/bin/clouddocs.ts b/infra/bin/clouddocs.ts index adef6fa..eef3425 100644 --- a/infra/bin/clouddocs.ts +++ b/infra/bin/clouddocs.ts @@ -34,9 +34,10 @@ const storage = new StorageStack(app, `${config.resourcePrefix}-storage`, { const api = new ApiStack(app, `${config.resourcePrefix}-api`, { env: config.env, - description: 'API Gateway HTTP v2 + healthcheck Lambda. Custom domain wired up in Phase 6.', + description: 'API Gateway HTTP v2 + auth/document Lambdas. Custom domain wired up in Phase 6.', tags, config, + uploadsBucket: storage.uploadsBucket, }); const observability = new ObservabilityStack(app, `${config.resourcePrefix}-observability`, { diff --git a/infra/lib/stacks/api-stack.ts b/infra/lib/stacks/api-stack.ts index f1328a0..9ed7d3b 100644 --- a/infra/lib/stacks/api-stack.ts +++ b/infra/lib/stacks/api-stack.ts @@ -3,6 +3,7 @@ import * as cdk from 'aws-cdk-lib'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import type * as s3 from 'aws-cdk-lib/aws-s3'; import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import type { Construct } from 'constructs'; import type { InfraConfig } from '../config'; @@ -10,6 +11,8 @@ import { NodejsHandler } from '../constructs/nodejs-handler'; export interface ApiStackProps extends cdk.StackProps { readonly config: InfraConfig; + /** Uploads bucket (from StorageStack) the document Lambdas presign against. */ + readonly uploadsBucket: s3.IBucket; } const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..'); @@ -62,6 +65,46 @@ const AUTH_ROUTES: readonly AuthRoute[] = [ }, ]; +interface DocRoute { + readonly id: string; + /** Path under handlers/documents/ for the handler.ts file. */ + readonly handlerDir: string; + readonly method: apigwv2.HttpMethod; + readonly path: string; + readonly description: string; +} + +const DOC_ROUTES: readonly DocRoute[] = [ + { + id: 'Create', + handlerDir: 'create', + method: apigwv2.HttpMethod.POST, + path: '/v1/documents', + description: 'Register a document and return a presigned S3 PUT URL.', + }, + { + id: 'List', + handlerDir: 'list', + method: apigwv2.HttpMethod.GET, + path: '/v1/documents', + description: 'Keyset-paginated list of the active org documents.', + }, + { + id: 'Complete', + handlerDir: 'complete', + method: apigwv2.HttpMethod.POST, + path: '/v1/documents/{id}/complete', + description: 'Mark a document uploaded after the browser PUT succeeds.', + }, + { + id: 'Download', + handlerDir: 'download', + method: apigwv2.HttpMethod.GET, + path: '/v1/documents/{id}/download', + description: 'Return a short-lived presigned GET URL for the object.', + }, +]; + export class ApiStack extends cdk.Stack { readonly httpApi: apigwv2.HttpApi; readonly apiSecret: secretsmanager.Secret; @@ -112,9 +155,10 @@ export class ApiStack extends cdk.Stack { apigwv2.CorsHttpMethod.OPTIONS, ], // `x-cdx-client` is the CSRF marker header required by refresh/logout - // (see apps/api middlewares/with-csrf.ts); listing it here makes the - // browser preflight succeed for allowed origins only. - allowHeaders: ['authorization', 'content-type', 'x-request-id', 'x-cdx-client'], + // (see apps/api middlewares/with-csrf.ts); `x-org-id` selects the active + // org for document routes (middlewares/with-active-org.ts). Listing them + // here makes the browser preflight succeed for allowed origins only. + allowHeaders: ['authorization', 'content-type', 'x-request-id', 'x-cdx-client', 'x-org-id'], allowCredentials: true, maxAge: cdk.Duration.hours(1), }, @@ -158,6 +202,38 @@ export class ApiStack extends cdk.Stack { }); } + // Document Lambdas — DB access (via the shared secret) plus read/write on the + // uploads bucket so they can presign PUT/GET URLs. The bytes flow browser↔S3 + // directly; these functions only sign URLs and touch metadata. + for (const route of DOC_ROUTES) { + const handler = new NodejsHandler(this, `Doc${route.id}`, { + functionName: `${config.resourcePrefix}-documents-${route.handlerDir}`, + entry: path.join(HANDLERS_ROOT, 'documents', route.handlerDir, 'handler.ts'), + environment: { + STAGE: config.stage, + SERVICE_VERSION: process.env.SERVICE_VERSION ?? '0.1.0', + SECRET_ARN: this.apiSecret.secretArn, + UPLOADS_BUCKET: props.uploadsBucket.bucketName, + LOG_LEVEL: config.stage === 'prod' ? 'info' : 'debug', + }, + timeout: cdk.Duration.seconds(15), + minify: config.stage === 'prod', + sourceMap: config.stage !== 'prod', + logRetention: logs.RetentionDays.TWO_WEEKS, + logRemovalPolicy: + config.stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, + }); + + this.apiSecret.grantRead(handler.function); + props.uploadsBucket.grantReadWrite(handler.function); + + this.httpApi.addRoutes({ + path: route.path, + methods: [route.method], + integration: new HttpLambdaIntegration(`Doc${route.id}Integration`, handler.function), + }); + } + // TODO Phase 6: bind custom domain (api-dev.) via DomainName + ApiMapping. if (config.customDomain.enabled) { throw new Error('Custom domain wiring not implemented yet — see Phase 6.'); diff --git a/libs/shared-types/src/index.ts b/libs/shared-types/src/index.ts index 5897af0..81b0b01 100644 --- a/libs/shared-types/src/index.ts +++ b/libs/shared-types/src/index.ts @@ -8,3 +8,4 @@ export * from './schemas/health'; export * from './schemas/auth'; export * from './schemas/orgs'; +export * from './schemas/documents'; diff --git a/libs/shared-types/src/schemas/documents.spec.ts b/libs/shared-types/src/schemas/documents.spec.ts new file mode 100644 index 0000000..2a90ae5 --- /dev/null +++ b/libs/shared-types/src/schemas/documents.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + CreateDocumentDtoSchema, + DocumentListQuerySchema, + MAX_UPLOAD_SIZE_BYTES, +} from './documents'; + +describe('CreateDocumentDtoSchema', () => { + const valid = { + filename: 'contract.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024, + }; + + it('accepts a valid PDF payload', () => { + expect(CreateDocumentDtoSchema.parse(valid)).toEqual(valid); + }); + + it('rejects unsupported mime types', () => { + expect(() => CreateDocumentDtoSchema.parse({ ...valid, mimeType: 'image/png' })).toThrow(); + }); + + it('rejects files over the 10 MB limit', () => { + expect(() => + CreateDocumentDtoSchema.parse({ ...valid, sizeBytes: MAX_UPLOAD_SIZE_BYTES + 1 }), + ).toThrow(); + }); + + it('rejects empty filenames', () => { + expect(() => CreateDocumentDtoSchema.parse({ ...valid, filename: '' })).toThrow(); + }); +}); + +describe('DocumentListQuerySchema', () => { + it('defaults limit to 20 and coerces string query params', () => { + expect(DocumentListQuerySchema.parse({})).toEqual({ limit: 20 }); + expect(DocumentListQuerySchema.parse({ limit: '50' }).limit).toBe(50); + }); + + it('caps limit at 100', () => { + expect(() => DocumentListQuerySchema.parse({ limit: '101' })).toThrow(); + }); +}); diff --git a/libs/shared-types/src/schemas/documents.ts b/libs/shared-types/src/schemas/documents.ts new file mode 100644 index 0000000..ac5265d --- /dev/null +++ b/libs/shared-types/src/schemas/documents.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; + +/** + * Document lifecycle. Phase 3 only produces `pending_upload` → `uploaded` + * (and `failed`); the extraction/analysis states are reserved for Phase 4's + * async pipeline but live here so the type is stable across phases. + */ +export const DocumentStatusSchema = z.enum([ + 'pending_upload', + 'uploaded', + 'extracting', + 'extracted', + 'analyzing', + 'ready', + 'failed', +]); +export type DocumentStatus = z.infer; + +/** MIME types accepted for upload in the MVP (PDF + DOCX). */ +export const ALLOWED_UPLOAD_MIME_TYPES = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +] as const; + +/** Max upload size for the MVP: 10 MB (plan §8 feature #3). */ +export const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024; + +const UploadMimeSchema = z.enum(ALLOWED_UPLOAD_MIME_TYPES); + +export const DocumentSchema = z.object({ + id: z.uuid(), + orgId: z.uuid(), + uploadedBy: z.uuid(), + filename: z.string().min(1).max(255), + mimeType: z.string(), + sizeBytes: z.number().int().nonnegative(), + status: DocumentStatusSchema, + error: z.string().nullable(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}); +export type Document = z.infer; + +/** + * Client sends the file's metadata; the server creates a `pending_upload` row + * and returns a presigned PUT URL the browser uploads to directly (the file + * bytes never pass through Lambda — plan §2.2). + */ +export const CreateDocumentDtoSchema = z.object({ + filename: z.string().min(1).max(255), + mimeType: UploadMimeSchema, + sizeBytes: z + .number() + .int() + .positive() + .max(MAX_UPLOAD_SIZE_BYTES, 'File exceeds the 10 MB limit.'), +}); +export type CreateDocumentDto = z.infer; + +/** Presigned S3 PUT the browser uploads the raw bytes to. */ +export const PresignedUploadSchema = z.object({ + url: z.string().url(), + /** Headers the browser must send with the PUT (e.g. Content-Type). */ + headers: z.record(z.string(), z.string()), + expiresInSeconds: z.number().int().positive(), +}); +export type PresignedUpload = z.infer; + +export const CreateDocumentResponseSchema = z.object({ + document: DocumentSchema, + upload: PresignedUploadSchema, +}); +export type CreateDocumentResponse = z.infer; + +export const DocumentListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + cursor: z.uuid().optional(), +}); +export type DocumentListQuery = z.infer; + +export const DocumentListResponseSchema = z.object({ + documents: z.array(DocumentSchema), + /** `id` to pass back as `cursor` for the next page; null when exhausted. */ + nextCursor: z.uuid().nullable(), +}); +export type DocumentListResponse = z.infer; + +export const DownloadResponseSchema = z.object({ + url: z.string().url(), + expiresInSeconds: z.number().int().positive(), +}); +export type DownloadResponse = z.infer; diff --git a/package.json b/package.json index f1ac7c1..540193f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "db:migrate:down": "dotenv -e .env.local -- node-pg-migrate -m tools/migrations -d DATABASE_URL -j sql down", "db:migrate:create": "dotenv -e .env.local -- node-pg-migrate -m tools/migrations -d DATABASE_URL -j sql create", "db:reset": "docker exec clouddocs-postgres psql -U clouddocs -d clouddocs -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' && pnpm db:migrate:up", - "test:integration": "dotenv -e .env.local -v RUN_INTEGRATION=1 -- vitest run --config apps/api/vitest.config.ts", + "test:integration": "dotenv -e .env.local -v RUN_INTEGRATION=1 -- vitest run --config apps/api/vitest.config.ts --no-file-parallelism", "secrets:put:dev": "node --experimental-strip-types tools/scripts/populate-dev-secrets.ts" }, "devDependencies": { @@ -31,7 +31,9 @@ "@angular/cli": "~21.2.0", "@angular/compiler-cli": "~21.2.0", "@angular/language-service": "~21.2.0", + "@aws-sdk/client-s3": "^3.1053.0", "@aws-sdk/client-secrets-manager": "^3.1052.0", + "@aws-sdk/s3-request-presigner": "^3.1053.0", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@eslint/js": "^9.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c43ed4..b482288 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,15 @@ importers: '@angular/language-service': specifier: ~21.2.0 version: 21.2.13 + '@aws-sdk/client-s3': + specifier: ^3.1053.0 + version: 3.1053.0 '@aws-sdk/client-secrets-manager': specifier: ^3.1052.0 version: 3.1052.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1053.0 + version: 3.1053.0 '@commitlint/cli': specifier: ^19.8.1 version: 19.8.1(@types/node@20.19.9)(typescript@5.9.3) @@ -489,6 +495,12 @@ packages: resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -502,6 +514,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/client-s3@3.1053.0': + resolution: {integrity: sha512-/oGxoB6p1Nqs935Blt+v1o+anSCEf2n3RjIrcLz84i4cn2Gr+Z7JpDdUkG5+74r5ctqEPG7k/phTGbJ9fNKnHg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-secrets-manager@3.1052.0': resolution: {integrity: sha512-G964bPvfCHoJhr4doV8vR1O5ujW5ldJDBxK+uX//GWlEMmJb8QVDPgmclrXVahg3824OlOwkW6R8dBkrJIvNlg==} engines: {node: '>=20.0.0'} @@ -510,6 +526,10 @@ packages: resolution: {integrity: sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==} engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.972.9': + resolution: {integrity: sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.39': resolution: {integrity: sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==} engines: {node: '>=20.0.0'} @@ -542,10 +562,38 @@ packages: resolution: {integrity: sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-bucket-endpoint@3.972.15': + resolution: {integrity: sha512-O2HDANa+MrvbxpaRVQDiH3T13uAa9AkMjKyZmDygwauAmmvqZ5B0iRmKW+fuVGW6NPXuyXurFgIx69lSvmAWGA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.13': + resolution: {integrity: sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.21': + resolution: {integrity: sha512-alAu9heyiBK/OmRNXVxq8mmPTgeW2AQ6EYjRsI38kPZa1MZvt2Jh+BlGq7/GG9OVXOaEgD7DlGj/Lzfy5OmuEg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.11': + resolution: {integrity: sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.42': + resolution: {integrity: sha512-/xNqNGXv9LaxZd25L9VV4pnSOw9OdDNO4rAHamM+h3KQBSITljIH9vk3dveGga1I2j36lQd0rdG3gjNEXvtNew==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.11': + resolution: {integrity: sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.11': resolution: {integrity: sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==} engines: {node: '>=20.0.0'} + '@aws-sdk/s3-request-presigner@3.1053.0': + resolution: {integrity: sha512-F2424BizKG4Jg/I7kr9bNCRqE1fo8ZnHBad+s3KalL20SwI1KCk7KnVJRdIpNVHbHqrhg/UZaLxaAjY7vLUUYw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.28': resolution: {integrity: sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==} engines: {node: '>=20.0.0'} @@ -8563,6 +8611,21 @@ snapshots: '@aws-sdk/types': 3.973.9 tslib: 2.8.1 + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -8589,6 +8652,27 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/client-s3@3.1053.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/credential-provider-node': 3.972.44 + '@aws-sdk/middleware-bucket-endpoint': 3.972.15 + '@aws-sdk/middleware-expect-continue': 3.972.13 + '@aws-sdk/middleware-flexible-checksums': 3.974.21 + '@aws-sdk/middleware-location-constraint': 3.972.11 + '@aws-sdk/middleware-sdk-s3': 3.972.42 + '@aws-sdk/middleware-ssec': 3.972.11 + '@aws-sdk/signature-v4-multi-region': 3.996.28 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/client-secrets-manager@3.1052.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -8613,6 +8697,11 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/crc64-nvme@3.972.9': + dependencies: + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.39': dependencies: '@aws-sdk/core': 3.974.13 @@ -8697,6 +8786,55 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.15': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.21': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/crc64-nvme': 3.972.9 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/signature-v4-multi-region': 3.996.28 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.11': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -8710,6 +8848,15 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/s3-request-presigner@3.1053.0': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/signature-v4-multi-region': 3.996.28 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.28': dependencies: '@aws-sdk/types': 3.973.9 diff --git a/tools/migrations/1779740244699_documents.sql b/tools/migrations/1779740244699_documents.sql new file mode 100644 index 0000000..5377371 --- /dev/null +++ b/tools/migrations/1779740244699_documents.sql @@ -0,0 +1,39 @@ +-- Up Migration + +-- Documents ----------------------------------------------------------------- +-- Phase 3 covers upload + S3 storage only. AI-derived columns (summary, +-- category, tags, language, page_count, text_s3_key), folders and versions +-- arrive with their respective phases (4 / 7) via later migrations — kept out +-- here so this migration maps cleanly to the feature it ships. +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + uploaded_by UUID NOT NULL REFERENCES users(id), + filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0), + s3_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending_upload' + CHECK (status IN ( + 'pending_upload', 'uploaded', 'extracting', 'extracted', + 'analyzing', 'ready', 'failed' + )), + error TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Every read path is org-scoped (OrgScopedRepository), so the org_id-leading +-- indexes match the access patterns: list newest-first, filter by status. +CREATE INDEX idx_documents_org_created ON documents(org_id, created_at DESC); +CREATE INDEX idx_documents_org_status ON documents(org_id, status); + +CREATE TRIGGER documents_updated_at + BEFORE UPDATE ON documents + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + +-- Down Migration + +DROP TABLE IF EXISTS documents;