From d4bc1ea3e3e9b92ed0f708f25e4854b2f23c7150 Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 17:38:59 -0300 Subject: [PATCH 01/26] docs: specify versioned docs api --- .../2026-06-12-docs-api-versioning-design.md | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md diff --git a/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md new file mode 100644 index 0000000..485920c --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md @@ -0,0 +1,437 @@ +# Docs API Versioning Design + +## Context + +This project is a Next.js 16.2.9 App Router application under `src/app`. API endpoints should be implemented as Route Handlers in `src/app/api/**/route.ts`, using the Web `Request` and `Response` APIs and following the local Next.js 16 documentation in `node_modules/next/dist/docs/`. + +The existing backend foundation is Clerk authentication, a synchronized local `users` table, Drizzle ORM, and Postgres. Clerk remains the source of truth for identity, while application authorization should use the local `users.id` row associated with the current Clerk user. + +The new documents API must store HTML documents, support multiple immutable versions per document, and allow document access through ownership or email-based sharing. + +## Goals + +- Add REST API endpoints for creating, reading, updating, listing, and sharing HTML documents. +- Store document metadata separately from versioned HTML content. +- Preserve every document version instead of overwriting HTML. +- Authorize reads for owners and users whose primary email was shared. +- Authorize updates only for owners. +- Authorize sharing for any user who currently has access to the document. +- Validate API request contracts and database write contracts with Zod. +- Centralize request user resolution in a reusable API context helper. + +## Non-Goals + +- No UI changes are included in this spec. +- No rich text sanitization or HTML rewriting is included. The API stores the submitted HTML as-is. +- No per-version sharing rules. If a user has access to a document, they have access to every version. +- No public link sharing. +- No document deletion endpoint. +- No metadata-only update endpoint for `name` or `description`. + +## API Context + +Create a shared server helper for API Route Handlers, for example `src/server/api/context.ts`. + +The helper should build an `ApiContext` for every protected API request. It should: + +- Read the current Clerk auth state. +- Require an authenticated Clerk user. +- Load the active local `users` row by `clerkUserId`. +- Normalize and expose the local user's primary email when present. +- Return a clear `401` response when no Clerk session exists. +- Return a clear `409` response when a Clerk session exists but the local user row has not been synchronized yet. +- Expose the local user as `ctx.user`. +- Expose the local user's normalized primary email as `ctx.userEmail`. +- Expose the Drizzle database handle as `ctx.db`. + +The context helper should avoid database work in `src/proxy.ts`. Proxy remains responsible only for coarse route protection. + +Suggested shape: + +```ts +type ApiContext = { + db: typeof db; + user: User; + userEmail: string | null; +}; + +type ApiContextResult = + | { ok: true; ctx: ApiContext } + | { ok: false; response: Response }; +``` + +Route handlers should call this helper at the start of each endpoint and return `result.response` when `ok` is false. This keeps endpoint files focused on request parsing, authorization, and document behavior. + +## Database Design + +Add three Postgres tables to `src/db/schema.ts`. + +### `documents` + +Stores stable document metadata and ownership. + +- `id`: text primary key, generated by the application with `crypto.randomUUID()`. +- `ownerUserId`: text, required, references `users.id`. +- `name`: text, required. +- `description`: text, nullable. +- `createdAt`: timestamptz, required. +- `updatedAt`: timestamptz, required. +- `deletedAt`: timestamptz, nullable. + +Indexes: + +- `documents_owner_user_id_idx` on `ownerUserId`. + +### `document_versions` + +Stores immutable HTML snapshots. + +- `id`: text primary key, generated by the application with `crypto.randomUUID()`. +- `documentId`: text, required, references `documents.id`. +- `versionNumber`: integer, required, starts at `1`. +- `html`: text, required. +- `createdByUserId`: text, required, references `users.id`. +- `createdAt`: timestamptz, required. + +Indexes: + +- Unique index `document_versions_document_id_version_unique` on `(documentId, versionNumber)`. +- Index `document_versions_document_id_created_at_idx` on `(documentId, createdAt)`. + +### `document_shares` + +Stores email-based access grants. The share target does not need to exist in `users` yet. + +- `id`: text primary key, generated by the application with `crypto.randomUUID()`. +- `documentId`: text, required, references `documents.id`. +- `sharedWithEmail`: text, required, stored lowercased and trimmed. +- `sharedByUserId`: text, required, references `users.id`. +- `createdAt`: timestamptz, required. + +Indexes: + +- Unique index `document_shares_document_id_email_unique` on `(documentId, sharedWithEmail)`. +- Index `document_shares_shared_with_email_idx` on `sharedWithEmail`. + +## Zod Contracts + +Create shared schemas for endpoint inputs and database inserts, for example under `src/server/docs/contracts.ts`. + +API schemas: + +- `createDocumentRequestSchema` + - `name`: non-empty string. + - `description`: optional string; blank strings may be normalized to `null`. + - `html`: non-empty string. +- `updateDocumentRequestSchema` + - `html`: non-empty string. +- `getDocumentQuerySchema` + - `version`: optional positive integer parsed from the query string. +- `listDocumentsQuerySchema` + - `access`: optional enum `"all" | "owned" | "shared"`, default `"all"`. +- `shareDocumentRequestSchema` + - `emails`: non-empty array of valid email strings. + +Database write schemas: + +- `newDocumentSchema`. +- `newDocumentVersionSchema`. +- `newDocumentShareSchema`. + +Database schemas should validate the full object immediately before insertion. Email fields should be normalized before DB validation. + +## Endpoint Contracts + +All document endpoints use `runtime = "nodejs"` because they access Postgres through the server database driver. + +Dynamic Route Handlers should follow the Next.js 16 route context shape where `params` is a promise. For example, `GET /api/docs/{id}` should await `ctx.params` before validating `id`. + +### `POST /api/docs` + +Creates a document and its initial version. + +Request body: + +```json +{ + "name": "Quarterly report", + "description": "Optional description", + "html": "

Report

" +} +``` + +Behavior: + +1. Build `ApiContext`. +2. Validate request body with Zod. +3. Insert `documents` with the current local user as owner. +4. Insert `document_versions` with `versionNumber = 1`. +5. Return `201`. + +Response body: + +```json +{ + "id": "doc_id", + "name": "Quarterly report", + "description": "Optional description", + "latestVersion": 1, + "ownerUserId": "user_id", + "createdAt": "2026-06-12T00:00:00.000Z", + "updatedAt": "2026-06-12T00:00:00.000Z" +} +``` + +### `GET /api/docs/{id}` + +Returns a document HTML version. + +Query string: + +- `version`: optional positive integer. Defaults to the latest version. + +Behavior: + +1. Build `ApiContext`. +2. Validate route params and query string with Zod. +3. Verify the current user can access the document. +4. Load the requested version or latest version. +5. Return `404` if the document or requested version does not exist. +6. Return `403` if the document exists but the current user cannot access it. + +Response body: + +```json +{ + "id": "doc_id", + "name": "Quarterly report", + "description": "Optional description", + "version": 2, + "latestVersion": 3, + "html": "

Report v2

", + "ownerUserId": "user_id", + "createdAt": "2026-06-12T00:00:00.000Z", + "updatedAt": "2026-06-12T00:00:00.000Z", + "versionCreatedAt": "2026-06-12T00:00:00.000Z" +} +``` + +### `PUT /api/docs/{id}` + +Creates a new immutable version for an existing document. + +Request body: + +```json +{ + "html": "

Updated report

" +} +``` + +Behavior: + +1. Build `ApiContext`. +2. Validate route params and body with Zod. +3. Load the document. +4. Return `404` if the document does not exist. +5. Return `403` if the current user is not the owner. +6. Compute the next version number as `latestVersion + 1`. +7. Insert a new `document_versions` row. +8. Update `documents.updatedAt`. +9. Return `200`. + +Response body: + +```json +{ + "id": "doc_id", + "version": 4, + "latestVersion": 4, + "updatedAt": "2026-06-12T00:00:00.000Z" +} +``` + +The implementation should use a transaction for the latest-version lookup and insert. The unique `(documentId, versionNumber)` index protects against duplicate version numbers if concurrent updates race. + +### `GET /api/docs` + +Lists documents the current user can access. + +Query string: + +- `access`: optional `"all" | "owned" | "shared"`, default `"all"`. + +Behavior: + +1. Build `ApiContext`. +2. Validate query string with Zod. +3. List documents where the current user is the owner and/or where `ctx.userEmail` matches `document_shares.sharedWithEmail`. +4. Exclude soft-deleted documents. +5. Include the latest version number for each document. +6. Sort by `documents.updatedAt` descending. + +Response body: + +```json +{ + "documents": [ + { + "id": "doc_id", + "name": "Quarterly report", + "description": "Optional description", + "access": "owned", + "latestVersion": 3, + "ownerUserId": "user_id", + "createdAt": "2026-06-12T00:00:00.000Z", + "updatedAt": "2026-06-12T00:00:00.000Z" + } + ] +} +``` + +If a user has no primary email, `access=shared` should return an empty list because there is no email to match. + +### `POST /api/docs/share/{id}` + +Shares a document with one or more email addresses. + +Request body: + +```json +{ + "emails": ["reader@example.com", "Reviewer@Example.com"] +} +``` + +Behavior: + +1. Build `ApiContext`. +2. Validate route params and body with Zod. +3. Normalize emails by trimming and lowercasing. +4. Remove duplicate emails from the request. +5. Verify the current user can access the document. +6. Return `404` if the document does not exist. +7. Return `403` if the document exists but the current user cannot access it. +8. Insert access grants idempotently. +9. Return `200`. + +Response body: + +```json +{ + "id": "doc_id", + "sharedWith": ["reader@example.com", "reviewer@example.com"] +} +``` + +Sharing is document-level. A shared email can read every existing and future version of that document. + +## Authorization Rules + +- Unauthenticated requests return `401`. +- Authenticated requests without a local `users` row return `409`. +- Owners can read, update, list, and share their documents. +- Shared users can read, list, and share documents shared with their primary email. +- Shared users cannot update documents. +- A user with no primary email can still own documents but cannot receive email-based shares until their local `primaryEmail` is present. +- Soft-deleted users should not be loaded by `ApiContext`. +- Soft-deleted documents should be excluded from all endpoints. + +## Error Responses + +Use JSON error bodies consistently: + +```json +{ + "error": { + "code": "validation_error", + "message": "Invalid request body" + } +} +``` + +Recommended status codes: + +- `400`: malformed JSON, invalid route param, invalid query string, invalid body. +- `401`: missing Clerk session. +- `403`: authenticated user lacks access. +- `404`: document or version not found. +- `409`: local user has not been synchronized yet, or a concurrent version insert conflicts after retry handling. +- `500`: unexpected server/database error. + +Validation errors should not echo submitted HTML. + +## File Layout + +Expected implementation files: + +```txt +src/app/api/docs/route.ts +src/app/api/docs/[id]/route.ts +src/app/api/docs/share/[id]/route.ts +src/server/api/context.ts +src/server/api/responses.ts +src/server/docs/contracts.ts +src/server/docs/repository.ts +src/server/docs/service.ts +``` + +`repository.ts` should contain database queries. `service.ts` should contain authorization and document workflows that are easier to test without Route Handler boilerplate. + +## Testing + +Use Vitest and follow the existing unit-test style. + +Test the contract layer: + +- Create request accepts valid input. +- Create request rejects missing name and empty HTML. +- Query schema parses positive integer versions. +- Share schema normalizes and deduplicates emails. +- DB insert schemas reject invalid persisted shapes. + +Test the API context helper: + +- Returns `401` when Clerk has no authenticated user. +- Returns `409` when a Clerk user exists but no active local user row exists. +- Returns `ApiContext` with local user and normalized email when present. + +Test the document service: + +- Create inserts metadata and initial version. +- Get latest version defaults correctly. +- Get specific version returns requested HTML. +- Owner can read and update. +- Shared email can read. +- Shared email cannot update. +- Owner and shared user can share. +- List returns owned, shared, or all based on the filter. +- User without primary email gets no shared documents. + +Test Route Handlers with mocked service/context dependencies where useful: + +- `POST /api/docs` returns `201`. +- `GET /api/docs/{id}` returns latest by default. +- `PUT /api/docs/{id}` returns `403` for non-owner. +- `GET /api/docs?access=shared` validates the filter. +- `POST /api/docs/share/{id}` validates emails. + +## Migration Plan + +1. Add Drizzle schema definitions for `documents`, `document_versions`, and `document_shares`. +2. Generate a Postgres migration with `bun run db:generate`. +3. Add Zod contracts. +4. Add the API context helper. +5. Add repository and service tests before implementation. +6. Add repository and service implementation. +7. Add Route Handler tests before each handler implementation. +8. Add Route Handlers. +9. Run `bun run test`, `bun run typecheck`, and `bun run lint`. + +## Open Decisions Resolved + +- Sharing is stored by normalized email, not by `userId`, so recipients do not need accounts before access is granted. +- Any user with document access can share the document onward. +- Updating a document creates a new version and does not mutate existing version rows. +- Reading without `version` returns the latest version. +- Listing defaults to all accessible documents. From 03bc7c43f28e58d2544ee60b4cf4d2915eb1cd70 Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 17:46:42 -0300 Subject: [PATCH 02/26] docs: define api endpoint foundation --- .../2026-06-12-docs-api-versioning-design.md | 106 ++++++++++++++++-- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md index 485920c..43692fc 100644 --- a/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md +++ b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md @@ -18,6 +18,7 @@ The new documents API must store HTML documents, support multiple immutable vers - Authorize sharing for any user who currently has access to the document. - Validate API request contracts and database write contracts with Zod. - Centralize request user resolution in a reusable API context helper. +- Create a reusable endpoint foundation for error handling, request parsing, database access, structured logs, and business-logic dispatch. ## Non-Goals @@ -62,6 +63,84 @@ type ApiContextResult = Route handlers should call this helper at the start of each endpoint and return `result.response` when `ok` is false. This keeps endpoint files focused on request parsing, authorization, and document behavior. +## API Endpoint Foundation + +Create a small server-side API foundation before implementing the document endpoints. The foundation should make Route Handlers consistent and keep framework code separate from business logic. + +Suggested files: + +```txt +src/server/api/context.ts +src/server/api/errors.ts +src/server/api/handler.ts +src/server/api/logger.ts +src/server/api/requests.ts +src/server/api/responses.ts +``` + +### Handler wrapper + +`src/server/api/handler.ts` should expose a wrapper such as `withApiHandler`. It should: + +- Build `ApiContext` once per request. +- Catch expected application errors and convert them to JSON responses. +- Catch unexpected errors, log them, and return a generic `500` response. +- Add a request id to every request if none is provided by upstream headers. +- Log request completion with method, pathname, status, duration, request id, and user id when available. +- Avoid logging submitted HTML or full request bodies. + +Suggested shape: + +```ts +type ApiHandler = (input: { + request: Request; + ctx: ApiContext; + params: TParams; +}) => Promise; + +function withApiHandler( + handler: ApiHandler, +): (request: Request, routeContext?: unknown) => Promise; +``` + +Dynamic route params should still be awaited and validated by the endpoint or request helper, because Next.js 16 exposes route `params` as a promise. + +### Errors and responses + +`src/server/api/errors.ts` should define typed application errors, for example `ApiError`, with a status code, stable error code, safe message, and optional validation details. Business logic should throw these typed errors for expected failures such as forbidden access, missing documents, validation failures, and local user sync conflicts. + +`src/server/api/responses.ts` should provide JSON helpers for success and error responses. Response helpers should produce one consistent envelope for errors and should avoid leaking stack traces, SQL details, Clerk secrets, or submitted HTML. + +### Request parsing + +`src/server/api/requests.ts` should contain reusable helpers for: + +- Reading JSON request bodies safely. +- Returning `400` for malformed JSON. +- Validating bodies, query strings, and route params with Zod. +- Converting `URLSearchParams` to plain objects before validation. + +Endpoint files should not duplicate try/catch blocks for JSON parsing or Zod formatting. + +### Database access + +Route Handlers should not import the global database directly. They should receive `ctx.db` from `ApiContext` and pass it into repositories or services. This makes tests easier and keeps database access consistent. + +Repository modules should own Drizzle queries. Service modules should own business rules and transaction orchestration. Route files should only compose the API foundation, request contracts, and service calls. + +### Logging + +`src/server/api/logger.ts` should expose a minimal structured logger wrapper around `console`. Logs should include: + +- `requestId`. +- HTTP method and pathname. +- Response status. +- Duration in milliseconds. +- `userId` when a local user was resolved. +- Stable error code for expected failures. + +Logs should not include raw HTML, complete request bodies, secret values, database URLs, or full email lists unless explicitly safe and necessary. For share operations, logging counts is safer than logging every email. + ## Database Design Add three Postgres tables to `src/db/schema.ts`. @@ -370,6 +449,10 @@ src/app/api/docs/route.ts src/app/api/docs/[id]/route.ts src/app/api/docs/share/[id]/route.ts src/server/api/context.ts +src/server/api/errors.ts +src/server/api/handler.ts +src/server/api/logger.ts +src/server/api/requests.ts src/server/api/responses.ts src/server/docs/contracts.ts src/server/docs/repository.ts @@ -396,6 +479,14 @@ Test the API context helper: - Returns `409` when a Clerk user exists but no active local user row exists. - Returns `ApiContext` with local user and normalized email when present. +Test the API foundation: + +- Handler wrapper catches typed `ApiError` instances and returns the expected status and JSON error envelope. +- Handler wrapper catches unexpected errors, logs them, and returns a generic `500`. +- Request parser returns `400` for malformed JSON. +- Zod request helpers format validation failures without echoing submitted HTML. +- Completion logs include request id, method, pathname, status, duration, and user id when available. + Test the document service: - Create inserts metadata and initial version. @@ -420,13 +511,14 @@ Test Route Handlers with mocked service/context dependencies where useful: 1. Add Drizzle schema definitions for `documents`, `document_versions`, and `document_shares`. 2. Generate a Postgres migration with `bun run db:generate`. -3. Add Zod contracts. -4. Add the API context helper. -5. Add repository and service tests before implementation. -6. Add repository and service implementation. -7. Add Route Handler tests before each handler implementation. -8. Add Route Handlers. -9. Run `bun run test`, `bun run typecheck`, and `bun run lint`. +3. Add the shared API foundation: errors, responses, request parsing, logging, handler wrapper, and API context. +4. Add foundation tests before implementation. +5. Add Zod contracts for docs endpoints and DB writes. +6. Add repository and service tests before implementation. +7. Add repository and service implementation. +8. Add Route Handler tests before each handler implementation. +9. Add Route Handlers using the shared handler wrapper. +10. Run `bun run test`, `bun run typecheck`, and `bun run lint`. ## Open Decisions Resolved From 4332cdd54eea9d81da632bb33d842de95f5cfcfd Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 17:52:51 -0300 Subject: [PATCH 03/26] docs: keep docs api modules under server api --- .../specs/2026-06-12-docs-api-versioning-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md index 43692fc..068e3e1 100644 --- a/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md +++ b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md @@ -194,7 +194,7 @@ Indexes: ## Zod Contracts -Create shared schemas for endpoint inputs and database inserts, for example under `src/server/docs/contracts.ts`. +Create shared schemas for endpoint inputs and database inserts, for example under `src/server/api/docs/contracts.ts`. API schemas: @@ -454,9 +454,9 @@ src/server/api/handler.ts src/server/api/logger.ts src/server/api/requests.ts src/server/api/responses.ts -src/server/docs/contracts.ts -src/server/docs/repository.ts -src/server/docs/service.ts +src/server/api/docs/contracts.ts +src/server/api/docs/repository.ts +src/server/api/docs/service.ts ``` `repository.ts` should contain database queries. `service.ts` should contain authorization and document workflows that are easier to test without Route Handler boilerplate. From 45a58738e8c03e2cec233586640efa093964dbfe Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 17:59:52 -0300 Subject: [PATCH 04/26] docs: align docs api architecture --- .../2026-06-12-docs-api-versioning-design.md | 77 ++++++++++--------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md index 068e3e1..d6d80e2 100644 --- a/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md +++ b/docs/superpowers/specs/2026-06-12-docs-api-versioning-design.md @@ -31,7 +31,7 @@ The new documents API must store HTML documents, support multiple immutable vers ## API Context -Create a shared server helper for API Route Handlers, for example `src/server/api/context.ts`. +Create a shared server helper for API Route Handlers in `src/server/foundation/context.ts`. The helper should build an `ApiContext` for every protected API request. It should: @@ -61,7 +61,7 @@ type ApiContextResult = | { ok: false; response: Response }; ``` -Route handlers should call this helper at the start of each endpoint and return `result.response` when `ok` is false. This keeps endpoint files focused on request parsing, authorization, and document behavior. +HTTP handlers should call this helper at the start of each endpoint and return `result.response` when `ok` is false. This keeps `src/app/api/**/route.ts` files as thin Next.js adapters and keeps request parsing, authorization, and document behavior in server modules. ## API Endpoint Foundation @@ -70,17 +70,16 @@ Create a small server-side API foundation before implementing the document endpo Suggested files: ```txt -src/server/api/context.ts -src/server/api/errors.ts -src/server/api/handler.ts -src/server/api/logger.ts -src/server/api/requests.ts -src/server/api/responses.ts +src/server/handlers/api.ts +src/server/handlers/docs.ts +src/server/foundation/context.ts +src/server/foundation/errors.ts +src/server/foundation/logs.ts ``` ### Handler wrapper -`src/server/api/handler.ts` should expose a wrapper such as `withApiHandler`. It should: +`src/server/handlers/api.ts` should expose a wrapper such as `withApiHandler`. It should: - Build `ApiContext` once per request. - Catch expected application errors and convert them to JSON responses. @@ -107,13 +106,13 @@ Dynamic route params should still be awaited and validated by the endpoint or re ### Errors and responses -`src/server/api/errors.ts` should define typed application errors, for example `ApiError`, with a status code, stable error code, safe message, and optional validation details. Business logic should throw these typed errors for expected failures such as forbidden access, missing documents, validation failures, and local user sync conflicts. +`src/server/foundation/errors.ts` should define typed application errors, for example `ApiError`, with a status code, stable error code, safe message, and optional validation details. Business logic should throw these typed errors for expected failures such as forbidden access, missing documents, validation failures, and local user sync conflicts. -`src/server/api/responses.ts` should provide JSON helpers for success and error responses. Response helpers should produce one consistent envelope for errors and should avoid leaking stack traces, SQL details, Clerk secrets, or submitted HTML. +The handler layer should provide JSON helpers for success and error responses, either in `src/server/handlers/api.ts` or a sibling file under `src/server/handlers`. Response helpers should produce one consistent envelope for errors and should avoid leaking stack traces, SQL details, Clerk secrets, or submitted HTML. ### Request parsing -`src/server/api/requests.ts` should contain reusable helpers for: +`src/server/handlers/api.ts`, or a sibling file under `src/server/handlers`, should contain reusable helpers for: - Reading JSON request bodies safely. - Returning `400` for malformed JSON. @@ -124,13 +123,13 @@ Endpoint files should not duplicate try/catch blocks for JSON parsing or Zod for ### Database access -Route Handlers should not import the global database directly. They should receive `ctx.db` from `ApiContext` and pass it into repositories or services. This makes tests easier and keeps database access consistent. +Next.js route files and HTTP handlers should not import the global database directly. They should receive `ctx.db` from `ApiContext` and pass it into repositories or services. This makes tests easier and keeps database access consistent. -Repository modules should own Drizzle queries. Service modules should own business rules and transaction orchestration. Route files should only compose the API foundation, request contracts, and service calls. +Repository modules under `src/repository/docs` should own Drizzle queries. Service modules under `src/services/docs` should own business rules and transaction orchestration. Handler files under `src/server/handlers` should only compose the API foundation, request contracts, and service calls. Next route files under `src/app/api` should only export the corresponding handler functions required by Next.js. ### Logging -`src/server/api/logger.ts` should expose a minimal structured logger wrapper around `console`. Logs should include: +`src/server/foundation/logs.ts` should expose a minimal structured logger wrapper around `console`. Logs should include: - `requestId`. - HTTP method and pathname. @@ -194,7 +193,7 @@ Indexes: ## Zod Contracts -Create shared schemas for endpoint inputs and database inserts, for example under `src/server/api/docs/contracts.ts`. +Create shared schemas for endpoint inputs, database inserts, and document-facing TypeScript types in `src/types/docs.ts`. API schemas: @@ -448,18 +447,21 @@ Expected implementation files: src/app/api/docs/route.ts src/app/api/docs/[id]/route.ts src/app/api/docs/share/[id]/route.ts -src/server/api/context.ts -src/server/api/errors.ts -src/server/api/handler.ts -src/server/api/logger.ts -src/server/api/requests.ts -src/server/api/responses.ts -src/server/api/docs/contracts.ts -src/server/api/docs/repository.ts -src/server/api/docs/service.ts +src/server/handlers/api.ts +src/server/handlers/docs.ts +src/server/foundation/context.ts +src/server/foundation/errors.ts +src/server/foundation/logs.ts +src/services/docs/create-document.ts +src/services/docs/get-document.ts +src/services/docs/list-documents.ts +src/services/docs/share-document.ts +src/services/docs/update-document.ts +src/repository/docs/documents.ts +src/types/docs.ts ``` -`repository.ts` should contain database queries. `service.ts` should contain authorization and document workflows that are easier to test without Route Handler boilerplate. +Files under `src/repository/docs` should contain database queries. Files under `src/services/docs` should contain authorization and document workflows that are easier to test without Route Handler boilerplate. `src/server/handlers/docs.ts` should parse HTTP inputs and call the document services. The `src/app/api/docs/**/route.ts` files should stay thin and export the matching Next.js Route Handler methods from `src/server/handlers/docs.ts`. ## Testing @@ -482,9 +484,9 @@ Test the API context helper: Test the API foundation: - Handler wrapper catches typed `ApiError` instances and returns the expected status and JSON error envelope. -- Handler wrapper catches unexpected errors, logs them, and returns a generic `500`. -- Request parser returns `400` for malformed JSON. -- Zod request helpers format validation failures without echoing submitted HTML. +- Handler wrapper catches unexpected errors, logs them through `src/server/foundation/logs.ts`, and returns a generic `500`. +- Request parser in the handler layer returns `400` for malformed JSON. +- Zod request helpers in the handler layer format validation failures without echoing submitted HTML. - Completion logs include request id, method, pathname, status, duration, and user id when available. Test the document service: @@ -499,7 +501,7 @@ Test the document service: - List returns owned, shared, or all based on the filter. - User without primary email gets no shared documents. -Test Route Handlers with mocked service/context dependencies where useful: +Test HTTP handlers with mocked service/context dependencies where useful: - `POST /api/docs` returns `201`. - `GET /api/docs/{id}` returns latest by default. @@ -511,14 +513,15 @@ Test Route Handlers with mocked service/context dependencies where useful: 1. Add Drizzle schema definitions for `documents`, `document_versions`, and `document_shares`. 2. Generate a Postgres migration with `bun run db:generate`. -3. Add the shared API foundation: errors, responses, request parsing, logging, handler wrapper, and API context. +3. Add the shared API foundation: errors, logging, and API context under `src/server/foundation`. 4. Add foundation tests before implementation. -5. Add Zod contracts for docs endpoints and DB writes. -6. Add repository and service tests before implementation. -7. Add repository and service implementation. -8. Add Route Handler tests before each handler implementation. -9. Add Route Handlers using the shared handler wrapper. -10. Run `bun run test`, `bun run typecheck`, and `bun run lint`. +5. Add the shared handler wrapper, response helpers, and request parsing under `src/server/handlers`. +6. Add Zod contracts and document types in `src/types/docs.ts`. +7. Add repository and service tests before implementation. +8. Add repository and service implementation under `src/repository/docs` and `src/services/docs`. +9. Add handler tests before each handler implementation. +10. Add thin Next.js Route Handlers in `src/app/api/docs/**/route.ts` that delegate to `src/server/handlers/docs.ts`. +11. Run `bun run test`, `bun run typecheck`, and `bun run lint`. ## Open Decisions Resolved From ca6556ade53f3558b05216a4c137d454c9356351 Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 18:04:24 -0300 Subject: [PATCH 05/26] docs: plan docs api endpoints --- .../plans/2026-06-12-docs-api-endpoints.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-docs-api-endpoints.md diff --git a/docs/superpowers/plans/2026-06-12-docs-api-endpoints.md b/docs/superpowers/plans/2026-06-12-docs-api-endpoints.md new file mode 100644 index 0000000..54f387d --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-docs-api-endpoints.md @@ -0,0 +1,341 @@ +# Docs API Endpoints Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build versioned HTML document APIs with shared endpoint foundation, authenticated API context, Drizzle persistence, Zod contracts, and tests. + +**Architecture:** Next.js `src/app/api/**/route.ts` files stay thin and delegate to HTTP handlers in `src/server/handlers/docs.ts`. Generic API infrastructure lives in `src/server/foundation` and `src/server/handlers/api.ts`; business rules live in `src/services/docs`; Drizzle queries live in `src/repository/docs`; Zod contracts and shared document types live in `src/types/docs.ts`. + +**Tech Stack:** Next.js 16 Route Handlers, Clerk, Drizzle ORM, Postgres, Zod 4, Vitest, Bun. + +--- + +### Task 1: Database Schema And Migration + +**Files:** +- Modify: `src/db/schema.ts` +- Create: `drizzle/0001_*.sql` via `bun run db:generate` +- Modify: `drizzle/meta/_journal.json` via Drizzle Kit +- Create/modify: `drizzle/meta/0001_snapshot.json` via Drizzle Kit + +- [ ] **Step 1: Add failing schema tests** + +Create `src/db/schema.test.ts` with assertions that `documents`, `documentVersions`, and `documentShares` are exported and expose the required Drizzle column keys: + +```ts +import { describe, expect, it } from "vitest"; + +import { documentShares, documentVersions, documents } from "./schema"; + +describe("documents schema", () => { + it("exports document tables with required columns", () => { + expect(documents.id).toBeDefined(); + expect(documents.ownerUserId).toBeDefined(); + expect(documentVersions.versionNumber).toBeDefined(); + expect(documentVersions.html).toBeDefined(); + expect(documentShares.sharedWithEmail).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Verify the test fails** + +Run: `bun run test src/db/schema.test.ts` + +Expected: fail because the document table exports do not exist. + +- [ ] **Step 3: Add Drizzle tables** + +Update `src/db/schema.ts` to define `documents`, `documentVersions`, and `documentShares` with the columns and indexes from the spec. Use `integer` and `foreignKey`/`.references()` from `drizzle-orm/pg-core` as appropriate. + +- [ ] **Step 4: Verify schema test passes** + +Run: `bun run test src/db/schema.test.ts` + +Expected: pass. + +- [ ] **Step 5: Generate migration** + +Run: `bun run db:generate` + +Expected: a new migration creates the three tables and indexes. + +### Task 2: Document Types And Zod Contracts + +**Files:** +- Create: `src/types/docs.ts` +- Create: `src/types/docs.test.ts` + +- [ ] **Step 1: Add failing contract tests** + +Create tests for request contracts and DB write contracts: + +```ts +import { describe, expect, it } from "vitest"; + +import { + createDocumentRequestSchema, + getDocumentQuerySchema, + listDocumentsQuerySchema, + newDocumentShareSchema, + shareDocumentRequestSchema, +} from "./docs"; + +describe("docs contracts", () => { + it("normalizes create document descriptions", () => { + expect( + createDocumentRequestSchema.parse({ + name: "Report", + description: " ", + html: "

Report

", + }), + ).toEqual({ name: "Report", description: null, html: "

Report

" }); + }); + + it("rejects empty HTML", () => { + expect(() => + createDocumentRequestSchema.parse({ name: "Report", html: "" }), + ).toThrow(); + }); + + it("parses version and list filters", () => { + expect(getDocumentQuerySchema.parse({ version: "2" })).toEqual({ version: 2 }); + expect(listDocumentsQuerySchema.parse({})).toEqual({ access: "all" }); + }); + + it("normalizes and deduplicates share emails", () => { + expect( + shareDocumentRequestSchema.parse({ + emails: [" Reader@Example.com ", "reader@example.com"], + }), + ).toEqual({ emails: ["reader@example.com"] }); + }); + + it("validates normalized DB share inserts", () => { + expect(() => + newDocumentShareSchema.parse({ + id: "share_1", + documentId: "doc_1", + sharedWithEmail: "Reader@Example.com", + sharedByUserId: "user_1", + createdAt: new Date(), + }), + ).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `bun run test src/types/docs.test.ts` + +Expected: fail because `src/types/docs.ts` does not exist. + +- [ ] **Step 3: Implement contracts** + +Create `src/types/docs.ts` with Zod schemas for request bodies, query strings, route params, DB inserts, and inferred TypeScript types. Normalize email with `trim().toLowerCase()`, deduplicate share email arrays, and normalize blank descriptions to `null`. + +- [ ] **Step 4: Verify contracts pass** + +Run: `bun run test src/types/docs.test.ts` + +Expected: pass. + +### Task 3: API Foundation And Context + +**Files:** +- Create: `src/server/foundation/errors.ts` +- Create: `src/server/foundation/logs.ts` +- Create: `src/server/foundation/context.ts` +- Create: `src/server/handlers/api.ts` +- Create: `src/server/foundation/context.test.ts` +- Create: `src/server/handlers/api.test.ts` + +- [ ] **Step 1: Add failing foundation tests** + +Write tests that prove: + +```ts +// context.test.ts +// - unauthenticated Clerk auth returns a 401 Response +// - authenticated Clerk user without local active row returns a 409 Response +// - active local user returns ctx.user, ctx.userEmail, and ctx.db + +// api.test.ts +// - withApiHandler converts ApiError into the expected JSON error response +// - withApiHandler converts unexpected errors into generic 500 JSON +// - parseJsonBody returns validation_error for malformed JSON +// - parseWithSchema does not include submitted HTML in validation details +``` + +Mock `@clerk/nextjs/server` and use a small Drizzle-like fake DB for context tests. + +- [ ] **Step 2: Verify foundation tests fail** + +Run: `bun run test src/server/foundation/context.test.ts src/server/handlers/api.test.ts` + +Expected: fail because foundation files do not exist. + +- [ ] **Step 3: Implement foundation** + +Implement: + +```ts +// errors.ts +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + public readonly details?: unknown, + ) { + super(message); + } +} +``` + +Add helper constructors for validation, unauthorized, forbidden, not found, conflict, and internal errors. Implement `logs.ts` as a thin wrapper around `console.info` and `console.error`. Implement `context.ts` with `createApiContext(database = db)` and `auth()` from Clerk. Implement `api.ts` with JSON response helpers, request id extraction, safe JSON parsing, Zod parsing, and `withApiHandler`. + +- [ ] **Step 4: Verify foundation passes** + +Run: `bun run test src/server/foundation/context.test.ts src/server/handlers/api.test.ts` + +Expected: pass. + +### Task 4: Repository And Services + +**Files:** +- Create: `src/repository/docs/documents.ts` +- Create: `src/services/docs/create-document.ts` +- Create: `src/services/docs/get-document.ts` +- Create: `src/services/docs/list-documents.ts` +- Create: `src/services/docs/share-document.ts` +- Create: `src/services/docs/update-document.ts` +- Create: `src/services/docs/service.test.ts` + +- [ ] **Step 1: Add failing service tests** + +Create service tests using an in-memory repository fake. Cover: + +```ts +// createDocument creates metadata and version 1 +// getDocument defaults to latest version +// getDocument returns a requested version +// shared email can read +// shared email cannot update +// owner can update and gets latestVersion + 1 +// owner and shared user can share +// listDocuments filters all, owned, and shared +// user without primary email gets no shared documents +``` + +- [ ] **Step 2: Verify service tests fail** + +Run: `bun run test src/services/docs/service.test.ts` + +Expected: fail because services do not exist. + +- [ ] **Step 3: Implement services** + +Services should accept `{ db, user, userEmail }` from `ApiContext`, create a repository with `createDocumentsRepository(db)`, enforce authorization, and throw `ApiError` for expected failures. `updateDocument` should call a repository transaction that computes the next version and inserts the new immutable version. + +- [ ] **Step 4: Implement repository** + +Implement Drizzle queries in `src/repository/docs/documents.ts`. Include: + +```ts +createDocumentWithInitialVersion(input) +findDocumentAccess(documentId, userId, userEmail) +findDocumentVersion(documentId, version?) +listAccessibleDocuments(userId, userEmail, access) +createDocumentVersion(documentId, userId, html) +shareDocument(documentId, sharedByUserId, emails) +``` + +Use `onConflictDoNothing` for idempotent share inserts. + +- [ ] **Step 5: Verify services pass** + +Run: `bun run test src/services/docs/service.test.ts` + +Expected: pass. + +### Task 5: HTTP Handlers And Next Route Adapters + +**Files:** +- Create: `src/server/handlers/docs.ts` +- Create: `src/server/handlers/docs.test.ts` +- Create: `src/app/api/docs/route.ts` +- Create: `src/app/api/docs/[id]/route.ts` +- Create: `src/app/api/docs/share/[id]/route.ts` + +- [ ] **Step 1: Add failing handler tests** + +Write handler tests with mocked document services. Cover: + +```ts +// POST /api/docs returns 201 and parses body +// GET /api/docs returns list response and validates access filter +// GET /api/docs/{id} awaits params and defaults latest version +// PUT /api/docs/{id} returns 403 when service throws forbidden ApiError +// POST /api/docs/share/{id} normalizes email input +``` + +- [ ] **Step 2: Verify handler tests fail** + +Run: `bun run test src/server/handlers/docs.test.ts` + +Expected: fail because handlers do not exist. + +- [ ] **Step 3: Implement HTTP handlers** + +Implement named exports in `src/server/handlers/docs.ts`, for example: + +```ts +export const POST = withApiHandler(async ({ request, ctx }) => { + const body = await parseJsonBody(request); + const input = parseWithSchema(createDocumentRequestSchema, body); + return jsonResponse(await createDocument(ctx, input), { status: 201 }); +}); +``` + +Use Next.js 16 async route params by awaiting `routeContext.params` inside the handler wrapper or handler. + +- [ ] **Step 4: Implement route adapters** + +Each `src/app/api/docs/**/route.ts` should export `runtime = "nodejs"` and delegate to the matching function from `src/server/handlers/docs.ts`. + +- [ ] **Step 5: Verify handlers pass** + +Run: `bun run test src/server/handlers/docs.test.ts` + +Expected: pass. + +### Task 6: Final Verification + +**Files:** +- All touched implementation and test files. + +- [ ] **Step 1: Run unit tests** + +Run: `bun run test` + +Expected: all tests pass. + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` + +Expected: exit code 0. + +- [ ] **Step 3: Run lint** + +Run: `bun run lint` + +Expected: exit code 0. + +- [ ] **Step 4: Review git diff** + +Run: `git diff --stat` and `git diff --check` + +Expected: no whitespace errors; diff only contains docs API implementation, migration, tests, and plan. From 1b1e8b34efb58a51c9f5418f90dab230d1e30445 Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 18:07:12 -0300 Subject: [PATCH 06/26] feat: add docs API type contracts --- src/types/docs.test.ts | 170 +++++++++++++++++++++++++++++++++++++++++ src/types/docs.ts | 110 ++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 src/types/docs.test.ts create mode 100644 src/types/docs.ts diff --git a/src/types/docs.test.ts b/src/types/docs.test.ts new file mode 100644 index 0000000..cab3a2b --- /dev/null +++ b/src/types/docs.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; + +import { + createDocumentRequestSchema, + documentRouteParamsSchema, + getDocumentQuerySchema, + listDocumentsQuerySchema, + newDocumentSchema, + newDocumentShareSchema, + newDocumentVersionSchema, + shareDocumentRequestSchema, + updateDocumentRequestSchema, +} from "./docs"; + +describe("docs contracts", () => { + it("normalizes create document input", () => { + expect( + createDocumentRequestSchema.parse({ + name: " Report ", + description: " ", + html: "

Report

", + }), + ).toEqual({ + name: "Report", + description: null, + html: "

Report

", + }); + + expect( + createDocumentRequestSchema.parse({ + name: "Report", + description: null, + html: "

Body

", + }), + ).toEqual({ name: "Report", description: null, html: "

Body

" }); + }); + + it("rejects empty create and update content", () => { + expect(() => + createDocumentRequestSchema.parse({ name: " ", html: "

Body

" }), + ).toThrow(); + + expect(() => + createDocumentRequestSchema.parse({ name: "Report", html: "" }), + ).toThrow(); + + expect(() => updateDocumentRequestSchema.parse({ html: "" })).toThrow(); + }); + + it("parses route params, versions, and list filters", () => { + expect(documentRouteParamsSchema.parse({ id: "doc_1" })).toEqual({ + id: "doc_1", + }); + expect(() => documentRouteParamsSchema.parse({ id: "" })).toThrow(); + + expect(getDocumentQuerySchema.parse({ version: "2" })).toEqual({ + version: 2, + }); + expect(getDocumentQuerySchema.parse({})).toEqual({}); + expect(() => getDocumentQuerySchema.parse({ version: "0" })).toThrow(); + expect(() => getDocumentQuerySchema.parse({ version: "1.5" })).toThrow(); + + expect(listDocumentsQuerySchema.parse({})).toEqual({ access: "all" }); + expect(listDocumentsQuerySchema.parse({ access: "shared" })).toEqual({ + access: "shared", + }); + expect(() => + listDocumentsQuerySchema.parse({ access: "unknown" }), + ).toThrow(); + }); + + it("normalizes and deduplicates share emails", () => { + expect( + shareDocumentRequestSchema.parse({ + emails: [ + " Reader@Example.com ", + "reader@example.com", + "reviewer@example.com", + ], + }), + ).toEqual({ + emails: ["reader@example.com", "reviewer@example.com"], + }); + + expect(() => shareDocumentRequestSchema.parse({ emails: [] })).toThrow(); + expect(() => + shareDocumentRequestSchema.parse({ emails: ["not-an-email"] }), + ).toThrow(); + }); + + it("validates DB insert objects", () => { + const createdAt = new Date("2026-06-12T00:00:00.000Z"); + + expect( + newDocumentSchema.parse({ + id: "doc_1", + ownerUserId: "user_1", + name: "Report", + description: null, + createdAt, + updatedAt: createdAt, + }), + ).toEqual({ + id: "doc_1", + ownerUserId: "user_1", + name: "Report", + description: null, + createdAt, + updatedAt: createdAt, + }); + + expect( + newDocumentVersionSchema.parse({ + id: "version_1", + documentId: "doc_1", + versionNumber: 1, + html: "

Body

", + createdByUserId: "user_1", + createdAt, + }), + ).toEqual({ + id: "version_1", + documentId: "doc_1", + versionNumber: 1, + html: "

Body

", + createdByUserId: "user_1", + createdAt, + }); + + expect( + newDocumentShareSchema.parse({ + id: "share_1", + documentId: "doc_1", + sharedWithEmail: "reader@example.com", + sharedByUserId: "user_1", + createdAt, + }), + ).toEqual({ + id: "share_1", + documentId: "doc_1", + sharedWithEmail: "reader@example.com", + sharedByUserId: "user_1", + createdAt, + }); + }); + + it("rejects non-normalized DB share emails", () => { + const createdAt = new Date("2026-06-12T00:00:00.000Z"); + + expect(() => + newDocumentShareSchema.parse({ + id: "share_1", + documentId: "doc_1", + sharedWithEmail: "Reader@Example.com", + sharedByUserId: "user_1", + createdAt, + }), + ).toThrow(); + + expect(() => + newDocumentShareSchema.parse({ + id: "share_1", + documentId: "doc_1", + sharedWithEmail: " reader@example.com ", + sharedByUserId: "user_1", + createdAt, + }), + ).toThrow(); + }); +}); diff --git a/src/types/docs.ts b/src/types/docs.ts new file mode 100644 index 0000000..2a2e4c9 --- /dev/null +++ b/src/types/docs.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +const nonEmptyTrimmedStringSchema = z + .string() + .trim() + .min(1, "Required"); + +const nonEmptyStringSchema = z.string().min(1, "Required"); + +const dateSchema = z.date(); + +const normalizedEmailSchema = z.string().trim().toLowerCase().email(); + +const storedEmailSchema = z + .string() + .email() + .refine((email) => email === email.trim(), { + message: "Email must be trimmed before insertion.", + }) + .refine((email) => email === email.toLowerCase(), { + message: "Email must be lowercased before insertion.", + }); + +const nullishDescriptionSchema = z + .string() + .trim() + .nullish() + .transform((description) => { + if (description === undefined || description === null || description === "") { + return null; + } + + return description; + }); + +export const createDocumentRequestSchema = z.object({ + name: nonEmptyTrimmedStringSchema, + description: nullishDescriptionSchema, + html: nonEmptyStringSchema, +}); + +export const updateDocumentRequestSchema = z.object({ + html: nonEmptyStringSchema, +}); + +export const getDocumentQuerySchema = z.object({ + version: z.coerce.number().int().positive().optional(), +}); + +export const listDocumentsQuerySchema = z.object({ + access: z.enum(["all", "owned", "shared"]).default("all"), +}); + +export const shareDocumentRequestSchema = z.object({ + emails: z + .array(normalizedEmailSchema) + .nonempty() + .transform((emails) => [...new Set(emails)]), +}); + +export const documentRouteParamsSchema = z.object({ + id: nonEmptyStringSchema, +}); + +export const newDocumentSchema = z + .object({ + id: nonEmptyStringSchema, + ownerUserId: nonEmptyStringSchema, + name: nonEmptyTrimmedStringSchema, + description: nullishDescriptionSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: dateSchema.nullable().optional(), + }) + .strict(); + +export const newDocumentVersionSchema = z + .object({ + id: nonEmptyStringSchema, + documentId: nonEmptyStringSchema, + versionNumber: z.number().int().positive(), + html: nonEmptyStringSchema, + createdByUserId: nonEmptyStringSchema, + createdAt: dateSchema, + }) + .strict(); + +export const newDocumentShareSchema = z + .object({ + id: nonEmptyStringSchema, + documentId: nonEmptyStringSchema, + sharedWithEmail: storedEmailSchema, + sharedByUserId: nonEmptyStringSchema, + createdAt: dateSchema, + }) + .strict(); + +export type CreateDocumentRequest = z.infer< + typeof createDocumentRequestSchema +>; +export type UpdateDocumentRequest = z.infer< + typeof updateDocumentRequestSchema +>; +export type GetDocumentQuery = z.infer; +export type ListDocumentsQuery = z.infer; +export type ShareDocumentRequest = z.infer; +export type DocumentRouteParams = z.infer; +export type NewDocument = z.infer; +export type NewDocumentVersion = z.infer; +export type NewDocumentShare = z.infer; From 49db1033595772bb73dd5bdb4fecc4398a6f466b Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 18:08:10 -0300 Subject: [PATCH 07/26] feat: add document schema tables --- drizzle/0001_wide_flatman.sql | 37 +++ drizzle/meta/0001_snapshot.json | 428 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.test.ts | 140 +++++++++++ src/db/schema.ts | 89 ++++++- 5 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 drizzle/0001_wide_flatman.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/db/schema.test.ts diff --git a/drizzle/0001_wide_flatman.sql b/drizzle/0001_wide_flatman.sql new file mode 100644 index 0000000..d9a98df --- /dev/null +++ b/drizzle/0001_wide_flatman.sql @@ -0,0 +1,37 @@ +CREATE TABLE "document_shares" ( + "id" text PRIMARY KEY NOT NULL, + "document_id" text NOT NULL, + "shared_with_email" text NOT NULL, + "shared_by_user_id" text NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "document_versions" ( + "id" text PRIMARY KEY NOT NULL, + "document_id" text NOT NULL, + "version_number" integer NOT NULL, + "html" text NOT NULL, + "created_by_user_id" text NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "documents" ( + "id" text PRIMARY KEY NOT NULL, + "owner_user_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "document_shares" ADD CONSTRAINT "document_shares_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "document_shares" ADD CONSTRAINT "document_shares_shared_by_user_id_users_id_fk" FOREIGN KEY ("shared_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "document_versions" ADD CONSTRAINT "document_versions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "document_shares_document_id_email_unique" ON "document_shares" USING btree ("document_id","shared_with_email");--> statement-breakpoint +CREATE INDEX "document_shares_shared_with_email_idx" ON "document_shares" USING btree ("shared_with_email");--> statement-breakpoint +CREATE UNIQUE INDEX "document_versions_document_id_version_unique" ON "document_versions" USING btree ("document_id","version_number");--> statement-breakpoint +CREATE INDEX "document_versions_document_id_created_at_idx" ON "document_versions" USING btree ("document_id","created_at");--> statement-breakpoint +CREATE INDEX "documents_owner_user_id_idx" ON "documents" USING btree ("owner_user_id"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..2426a17 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,428 @@ +{ + "id": "b9031c25-0d22-4e52-be63-f7b67491fd41", + "prevId": "2c4937ce-8f75-4996-8915-f538a28ffdaf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.document_shares": { + "name": "document_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_with_email": { + "name": "shared_with_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_by_user_id": { + "name": "shared_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "document_shares_document_id_email_unique": { + "name": "document_shares_document_id_email_unique", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_with_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_shares_shared_with_email_idx": { + "name": "document_shares_shared_with_email_idx", + "columns": [ + { + "expression": "shared_with_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_shares_document_id_documents_id_fk": { + "name": "document_shares_document_id_documents_id_fk", + "tableFrom": "document_shares", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_shares_shared_by_user_id_users_id_fk": { + "name": "document_shares_shared_by_user_id_users_id_fk", + "tableFrom": "document_shares", + "tableTo": "users", + "columnsFrom": [ + "shared_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_versions": { + "name": "document_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "document_versions_document_id_version_unique": { + "name": "document_versions_document_id_version_unique", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_versions_document_id_created_at_idx": { + "name": "document_versions_document_id_created_at_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_versions_document_id_documents_id_fk": { + "name": "document_versions_document_id_documents_id_fk", + "tableFrom": "document_versions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_versions_created_by_user_id_users_id_fk": { + "name": "document_versions_created_by_user_id_users_id_fk", + "tableFrom": "document_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "documents_owner_user_id_idx": { + "name": "documents_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_owner_user_id_users_id_fk": { + "name": "documents_owner_user_id_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_clerk_user_id_unique": { + "name": "users_clerk_user_id_unique", + "columns": [ + { + "expression": "clerk_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_primary_email_idx": { + "name": "users_primary_email_idx", + "columns": [ + { + "expression": "primary_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d2ebd60..f2b6342 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781227457832, "tag": "0000_create_users_table", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781298395771, + "tag": "0001_wide_flatman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.test.ts b/src/db/schema.test.ts new file mode 100644 index 0000000..e71f63f --- /dev/null +++ b/src/db/schema.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { getTableColumns, getTableName } from "drizzle-orm"; + +import * as schema from "./schema"; + +type TableExport = Parameters[0]; + +function expectTableExport(name: keyof typeof schema, tableName: string) { + const table = schema[name]; + + expect(table).toBeDefined(); + expect(getTableName(table as TableExport)).toBe(tableName); + + return getTableColumns(table as TableExport); +} + +function expectColumn( + column: { name: string; notNull: boolean; primary: boolean; columnType: string }, + expected: { + name: string; + notNull: boolean; + primary?: boolean; + columnType: string; + }, +) { + expect(column.name).toBe(expected.name); + expect(column.notNull).toBe(expected.notNull); + expect(column.primary).toBe(expected.primary ?? false); + expect(column.columnType).toBe(expected.columnType); +} + +describe("document schema", () => { + it("exports documents with required columns", () => { + const columns = expectTableExport("documents", "documents"); + + expectColumn(columns.id, { + name: "id", + notNull: true, + primary: true, + columnType: "PgText", + }); + expectColumn(columns.ownerUserId, { + name: "owner_user_id", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.name, { + name: "name", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.description, { + name: "description", + notNull: false, + columnType: "PgText", + }); + expectColumn(columns.createdAt, { + name: "created_at", + notNull: true, + columnType: "PgTimestamp", + }); + expectColumn(columns.updatedAt, { + name: "updated_at", + notNull: true, + columnType: "PgTimestamp", + }); + expectColumn(columns.deletedAt, { + name: "deleted_at", + notNull: false, + columnType: "PgTimestamp", + }); + }); + + it("exports documentVersions with required columns", () => { + const columns = expectTableExport("documentVersions", "document_versions"); + + expectColumn(columns.id, { + name: "id", + notNull: true, + primary: true, + columnType: "PgText", + }); + expectColumn(columns.documentId, { + name: "document_id", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.versionNumber, { + name: "version_number", + notNull: true, + columnType: "PgInteger", + }); + expectColumn(columns.html, { + name: "html", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.createdByUserId, { + name: "created_by_user_id", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.createdAt, { + name: "created_at", + notNull: true, + columnType: "PgTimestamp", + }); + }); + + it("exports documentShares with required columns", () => { + const columns = expectTableExport("documentShares", "document_shares"); + + expectColumn(columns.id, { + name: "id", + notNull: true, + primary: true, + columnType: "PgText", + }); + expectColumn(columns.documentId, { + name: "document_id", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.sharedWithEmail, { + name: "shared_with_email", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.sharedByUserId, { + name: "shared_by_user_id", + notNull: true, + columnType: "PgText", + }); + expectColumn(columns.createdAt, { + name: "created_at", + notNull: true, + columnType: "PgTimestamp", + }); + }); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index bcf9c3b..77e1be2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,11 @@ -import { index, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; export const users = pgTable( "users", @@ -26,3 +33,83 @@ export const users = pgTable( export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; + +export const documents = pgTable( + "documents", + { + id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + description: text("description"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdateFn(() => new Date()), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (table) => [index("documents_owner_user_id_idx").on(table.ownerUserId)], +); + +export const documentVersions = pgTable( + "document_versions", + { + id: text("id").primaryKey(), + documentId: text("document_id") + .notNull() + .references(() => documents.id), + versionNumber: integer("version_number").notNull(), + html: text("html").notNull(), + createdByUserId: text("created_by_user_id") + .notNull() + .references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => [ + uniqueIndex("document_versions_document_id_version_unique").on( + table.documentId, + table.versionNumber, + ), + index("document_versions_document_id_created_at_idx").on( + table.documentId, + table.createdAt, + ), + ], +); + +export const documentShares = pgTable( + "document_shares", + { + id: text("id").primaryKey(), + documentId: text("document_id") + .notNull() + .references(() => documents.id), + sharedWithEmail: text("shared_with_email").notNull(), + sharedByUserId: text("shared_by_user_id") + .notNull() + .references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => [ + uniqueIndex("document_shares_document_id_email_unique").on( + table.documentId, + table.sharedWithEmail, + ), + index("document_shares_shared_with_email_idx").on(table.sharedWithEmail), + ], +); + +export type Document = typeof documents.$inferSelect; +export type NewDocument = typeof documents.$inferInsert; +export type DocumentVersion = typeof documentVersions.$inferSelect; +export type NewDocumentVersion = typeof documentVersions.$inferInsert; +export type DocumentShare = typeof documentShares.$inferSelect; +export type NewDocumentShare = typeof documentShares.$inferInsert; From 6f0e7ef0bbf93d6a8d0aff02dd1e373f84c7017b Mon Sep 17 00:00:00 2001 From: Ana Berg Date: Fri, 12 Jun 2026 18:08:53 -0300 Subject: [PATCH 08/26] feat: add api endpoint foundation --- src/server/foundation/context.test.ts | 80 +++++++++++++ src/server/foundation/context.ts | 73 ++++++++++++ src/server/foundation/errors.ts | 44 +++++++ src/server/foundation/logs.ts | 26 +++++ src/server/handlers/api.test.ts | 77 +++++++++++++ src/server/handlers/api.ts | 160 ++++++++++++++++++++++++++ 6 files changed, 460 insertions(+) create mode 100644 src/server/foundation/context.test.ts create mode 100644 src/server/foundation/context.ts create mode 100644 src/server/foundation/errors.ts create mode 100644 src/server/foundation/logs.ts create mode 100644 src/server/handlers/api.test.ts create mode 100644 src/server/handlers/api.ts diff --git a/src/server/foundation/context.test.ts b/src/server/foundation/context.test.ts new file mode 100644 index 0000000..09d3e47 --- /dev/null +++ b/src/server/foundation/context.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn(), +})); + +describe("createApiContext", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("returns 401 when Clerk has no authenticated user", async () => { + const { auth } = await import("@clerk/nextjs/server"); + vi.mocked(auth).mockResolvedValue({ userId: null } as never); + + const { createApiContext } = await import("./context"); + const result = await createApiContext(fakeDb()); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(401); + await expect(result.response.json()).resolves.toMatchObject({ + error: { code: "unauthorized" }, + }); + } + }); + + it("returns 409 when the Clerk user has no active local user row", async () => { + const { auth } = await import("@clerk/nextjs/server"); + vi.mocked(auth).mockResolvedValue({ userId: "clerk_123" } as never); + + const { createApiContext } = await import("./context"); + const result = await createApiContext(fakeDb(null)); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(409); + await expect(result.response.json()).resolves.toMatchObject({ + error: { code: "user_not_synced" }, + }); + } + }); + + it("returns context with local user, normalized email, and db", async () => { + const { auth } = await import("@clerk/nextjs/server"); + vi.mocked(auth).mockResolvedValue({ userId: "clerk_123" } as never); + const database = fakeDb({ + id: "user_1", + clerkUserId: "clerk_123", + primaryEmail: " Ada@Example.com ", + firstName: null, + lastName: null, + imageUrl: null, + createdAt: new Date("2026-06-12T00:00:00.000Z"), + updatedAt: new Date("2026-06-12T00:00:00.000Z"), + deletedAt: null, + }); + + const { createApiContext } = await import("./context"); + const result = await createApiContext(database); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ctx.db).toBe(database); + expect(result.ctx.user.id).toBe("user_1"); + expect(result.ctx.userEmail).toBe("ada@example.com"); + } + }); +}); + +function fakeDb(user: unknown = null) { + return { + query: { + users: { + findFirst: vi.fn().mockResolvedValue(user), + }, + }, + }; +} diff --git a/src/server/foundation/context.ts b/src/server/foundation/context.ts new file mode 100644 index 0000000..efa36a7 --- /dev/null +++ b/src/server/foundation/context.ts @@ -0,0 +1,73 @@ +import { auth } from "@clerk/nextjs/server"; +import { and, eq, isNull } from "drizzle-orm"; + +import { db, type User, users } from "@/db"; + +type Db = typeof db; + +export type ApiContext = { + db: Db; + requestId: string; + user: User; + userEmail: string | null; +}; + +export type ApiContextResult = + | { ok: true; ctx: ApiContext } + | { ok: false; response: Response }; + +type DbLike = Db & { + query: { + users: { + findFirst(args: unknown): Promise; + }; + }; +}; + +export async function createApiContext( + database: DbLike = db as DbLike, + requestId = crypto.randomUUID(), +): Promise { + const session = await auth(); + + if (!session.userId) { + return { + ok: false, + response: errorResponse("unauthorized", "Authentication required", 401), + }; + } + + const user = await database.query.users.findFirst({ + where: and(eq(users.clerkUserId, session.userId), isNull(users.deletedAt)), + }); + + if (!user) { + return { + ok: false, + response: errorResponse( + "user_not_synced", + "Authenticated user has not been synchronized yet", + 409, + ), + }; + } + + return { + ok: true, + ctx: { + db: database, + requestId, + user, + userEmail: normalizeEmail(user.primaryEmail), + }, + }; +} + +function normalizeEmail(email: string | null): string | null { + const normalized = email?.trim().toLowerCase(); + return normalized === "" ? null : normalized ?? null; +} + +function errorResponse(code: string, message: string, status: number): Response { + return Response.json({ error: { code, message } }, { status }); +} diff --git a/src/server/foundation/errors.ts b/src/server/foundation/errors.ts new file mode 100644 index 0000000..3dc3422 --- /dev/null +++ b/src/server/foundation/errors.ts @@ -0,0 +1,44 @@ +export type ApiErrorCode = + | "validation_error" + | "unauthorized" + | "forbidden" + | "not_found" + | "conflict" + | "user_not_synced" + | "internal_error"; + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: ApiErrorCode, + message: string, + public readonly details?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export function validationError(message = "Invalid request", details?: unknown) { + return new ApiError(400, "validation_error", message, details); +} + +export function unauthorizedError(message = "Authentication required") { + return new ApiError(401, "unauthorized", message); +} + +export function forbiddenError(message = "Forbidden") { + return new ApiError(403, "forbidden", message); +} + +export function notFoundError(message = "Not found") { + return new ApiError(404, "not_found", message); +} + +export function conflictError(message = "Conflict", code: ApiErrorCode = "conflict") { + return new ApiError(409, code, message); +} + +export function internalError(message = "Internal server error") { + return new ApiError(500, "internal_error", message); +} diff --git a/src/server/foundation/logs.ts b/src/server/foundation/logs.ts new file mode 100644 index 0000000..3677c82 --- /dev/null +++ b/src/server/foundation/logs.ts @@ -0,0 +1,26 @@ +type ApiLogFields = { + requestId: string; + method: string; + pathname: string; + status: number; + durationMs: number; + userId?: string; + errorCode?: string; +}; + +export function logApiRequest(fields: ApiLogFields): void { + console.info("api_request", fields); +} + +export function logApiError( + error: unknown, + fields: Omit & { + status?: number; + durationMs?: number; + }, +): void { + console.error("api_error", { + ...fields, + errorName: error instanceof Error ? error.name : "UnknownError", + }); +} diff --git a/src/server/handlers/api.test.ts b/src/server/handlers/api.test.ts new file mode 100644 index 0000000..6b8ec82 --- /dev/null +++ b/src/server/handlers/api.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { ApiError } from "@/server/foundation/errors"; + +vi.mock("@/server/foundation/context", () => ({ + createApiContext: vi.fn().mockResolvedValue({ + ok: true, + ctx: { + db: {}, + user: { id: "user_1" }, + userEmail: "ada@example.com", + requestId: "req_test", + }, + }), +})); + +vi.mock("@/server/foundation/logs", () => ({ + logApiError: vi.fn(), + logApiRequest: vi.fn(), +})); + +describe("API handler foundation", () => { + it("converts ApiError into a JSON error response", async () => { + const { withApiHandler } = await import("./api"); + const handler = withApiHandler(async () => { + throw new ApiError(403, "forbidden", "Forbidden"); + }); + + const response = await handler(new Request("https://app.test/api/docs")); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: { code: "forbidden", message: "Forbidden" }, + }); + }); + + it("converts unexpected errors into generic 500 JSON", async () => { + const { withApiHandler } = await import("./api"); + const handler = withApiHandler(async () => { + throw new Error("database password leaked"); + }); + + const response = await handler(new Request("https://app.test/api/docs")); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: { code: "internal_error", message: "Internal server error" }, + }); + }); + + it("returns validation_error for malformed JSON", async () => { + const { parseJsonBody } = await import("./api"); + + await expect( + parseJsonBody( + new Request("https://app.test/api/docs", { + method: "POST", + body: "{", + }), + ), + ).rejects.toMatchObject({ status: 400, code: "validation_error" }); + }); + + it("formats Zod validation failures without echoing submitted HTML", async () => { + const { parseWithSchema } = await import("./api"); + const schema = z.object({ name: z.string().min(1) }); + + try { + parseWithSchema(schema, { name: "", html: "" }); + throw new Error("expected parseWithSchema to throw"); + } catch (error) { + expect(error).toMatchObject({ status: 400, code: "validation_error" }); + expect(JSON.stringify(error)).not.toContain("" }); - throw new Error("expected parseWithSchema to throw"); - } catch (error) { - expect(error).toMatchObject({ status: 400, code: "validation_error" }); - expect(JSON.stringify(error)).not.toContain("