Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/api/src/handlers/documents/complete/handler.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
),
),
),
);
41 changes: 41 additions & 0 deletions apps/api/src/handlers/documents/create/handler.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
),
),
),
),
);
33 changes: 33 additions & 0 deletions apps/api/src/handlers/documents/create/usecase.ts
Original file line number Diff line number Diff line change
@@ -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<CreateDocumentResponse> {
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 };
}
199 changes: 199 additions & 0 deletions apps/api/src/handlers/documents/documents.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../lib/storage/s3')>();
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> = {}): 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);
});
});
39 changes: 39 additions & 0 deletions apps/api/src/handlers/documents/download/handler.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
),
),
),
);
35 changes: 35 additions & 0 deletions apps/api/src/handlers/documents/list/handler.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
),
),
),
);
Loading
Loading