diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d82061..2f01f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,8 @@ ## [1.7.2](https://github.com/typelets/typelets-api/compare/v1.7.1...v1.7.2) (2025-10-15) - ### Bug Fixes -* restore JSON string format for CloudWatch compatibility ([dc2b7aa](https://github.com/typelets/typelets-api/commit/dc2b7aa8474ade322fcc383db7b103f1796ef6c4)) +- restore JSON string format for CloudWatch compatibility ([dc2b7aa](https://github.com/typelets/typelets-api/commit/dc2b7aa8474ade322fcc383db7b103f1796ef6c4)) ## [1.7.1](https://github.com/typelets/typelets-api/compare/v1.7.0...v1.7.1) (2025-10-15) diff --git a/README.md b/README.md index da2e615..dd07c92 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ If you prefer to install PostgreSQL locally instead of Docker: ## API Endpoints +📚 **Full API documentation with interactive examples available at [https://api.typelets.com/docs](https://api.typelets.com/docs)** (Swagger/OpenAPI) + ### Public Endpoints - `GET /` - API information and health status diff --git a/package.json b/package.json index 5d960ae..08aa79f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "dependencies": { "@clerk/backend": "^2.5.0", "@hono/node-server": "^1.15.0", - "@hono/zod-validator": "^0.7.0", + "@hono/swagger-ui": "^0.5.2", + "@hono/zod-openapi": "^1.1.3", + "@hono/zod-validator": "^0.7.2", "@types/ws": "^8.18.1", "dotenv": "^17.0.1", "dotenv-flow": "^4.1.0", @@ -49,7 +51,7 @@ "newrelic": "latest", "postgres": "^3.4.7", "ws": "^8.18.3", - "zod": "^3.25.67" + "zod": "^4.1.12" }, "devDependencies": { "@eslint/js": "^9.37.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 987c47d..1306e74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: '@hono/node-server': specifier: ^1.15.0 version: 1.19.1(hono@4.9.6) + '@hono/swagger-ui': + specifier: ^0.5.2 + version: 0.5.2(hono@4.9.6) + '@hono/zod-openapi': + specifier: ^1.1.3 + version: 1.1.3(hono@4.9.6)(zod@4.1.12) '@hono/zod-validator': - specifier: ^0.7.0 - version: 0.7.2(hono@4.9.6)(zod@3.25.76) + specifier: ^0.7.2 + version: 0.7.2(hono@4.9.6)(zod@4.1.12) '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -45,8 +51,8 @@ importers: specifier: ^8.18.3 version: 8.18.3 zod: - specifier: ^3.25.67 - version: 3.25.76 + specifier: ^4.1.12 + version: 4.1.12 devDependencies: '@eslint/js': specifier: ^9.37.0 @@ -129,6 +135,11 @@ packages: '@apm-js-collab/tracing-hooks@0.3.1': resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} + '@asteasolutions/zod-to-openapi@8.1.0': + resolution: {integrity: sha512-tQFxVs05J/6QXXqIzj6rTRk3nj1HFs4pe+uThwE95jL5II2JfpVXkK+CqkO7aT0Do5AYqO6LDrKpleLUFXgY+g==} + peerDependencies: + zod: ^4.0.0 + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -672,12 +683,30 @@ packages: peerDependencies: hono: ^4 + '@hono/swagger-ui@0.5.2': + resolution: {integrity: sha512-7wxLKdb8h7JTdZ+K8DJNE3KXQMIpJejkBTQjrYlUWF28Z1PGOKw6kUykARe5NTfueIN37jbyG/sBYsbzXzG53A==} + peerDependencies: + hono: '*' + + '@hono/zod-openapi@1.1.3': + resolution: {integrity: sha512-ikA8p0Jt7yplxOqbYwdh8rCQWaGN4bu8zK1HbCWqfWT9clo87L32D0eAQ/r0tJodtZbTV5d1vPB75FCkUt1Jdg==} + engines: {node: '>=16.0.0'} + peerDependencies: + hono: '>=4.3.6' + zod: ^4.0.0 + '@hono/zod-validator@0.7.2': resolution: {integrity: sha512-ub5eL/NeZ4eLZawu78JpW/J+dugDAYhwqUIdp9KYScI6PZECij4Hx4UsrthlEUutqDDhPwRI0MscUfNkvn/mqQ==} peerDependencies: hono: '>=3.9.0' zod: ^3.25.0 || ^4.0.0 + '@hono/zod-validator@0.7.4': + resolution: {integrity: sha512-biKGn3BRJVaftZlIPMyK+HCe/UHAjJ6sH0UyXe3+v0OcgVr9xfImDROTJFLtn9e3XEEAHGZIM9U6evu85abm8Q==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.25.0 || ^4.0.0 + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -2971,6 +3000,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + openapi3-ts@4.5.0: + resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3731,6 +3763,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -3755,8 +3792,8 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} snapshots: @@ -3770,6 +3807,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@asteasolutions/zod-to-openapi@8.1.0(zod@4.1.12)': + dependencies: + openapi3-ts: 4.5.0 + zod: 4.1.12 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -4210,10 +4252,27 @@ snapshots: dependencies: hono: 4.9.6 - '@hono/zod-validator@0.7.2(hono@4.9.6)(zod@3.25.76)': + '@hono/swagger-ui@0.5.2(hono@4.9.6)': + dependencies: + hono: 4.9.6 + + '@hono/zod-openapi@1.1.3(hono@4.9.6)(zod@4.1.12)': + dependencies: + '@asteasolutions/zod-to-openapi': 8.1.0(zod@4.1.12) + '@hono/zod-validator': 0.7.4(hono@4.9.6)(zod@4.1.12) + hono: 4.9.6 + openapi3-ts: 4.5.0 + zod: 4.1.12 + + '@hono/zod-validator@0.7.2(hono@4.9.6)(zod@4.1.12)': + dependencies: + hono: 4.9.6 + zod: 4.1.12 + + '@hono/zod-validator@0.7.4(hono@4.9.6)(zod@4.1.12)': dependencies: hono: 4.9.6 - zod: 3.25.76 + zod: 4.1.12 '@humanwhocodes/config-array@0.13.0': dependencies: @@ -6813,6 +6872,10 @@ snapshots: dependencies: mimic-fn: 4.0.0 + openapi3-ts@4.5.0: + dependencies: + yaml: 2.8.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7553,6 +7616,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.8.1: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} @@ -7581,4 +7646,4 @@ snapshots: yoctocolors@2.1.2: {} - zod@3.25.76: {} + zod@4.1.12: {} diff --git a/src/lib/openapi-schemas.ts b/src/lib/openapi-schemas.ts new file mode 100644 index 0000000..a861432 --- /dev/null +++ b/src/lib/openapi-schemas.ts @@ -0,0 +1,599 @@ +import { z } from "@hono/zod-openapi"; + +// User schemas +export const userSchema = z + .object({ + id: z + .string() + .openapi({ example: "user_2rbRo9aVQTbhEOmwtORqRLtRPXZ", description: "Clerk user ID" }), + email: z.string().email().openapi({ example: "user@example.com", description: "User email" }), + firstName: z.string().nullable().openapi({ example: "Rui", description: "User first name" }), + lastName: z.string().nullable().openapi({ example: "Costa", description: "User last name" }), + createdAt: z + .string() + .datetime() + .openapi({ example: "2025-07-24T19:41:24.451Z", description: "Account creation date" }), + updatedAt: z + .string() + .datetime() + .openapi({ example: "2025-07-24T19:41:24.451Z", description: "Last update date" }), + }) + .openapi("User"); + +export const storageUsageSchema = z + .object({ + totalBytes: z.number().openapi({ example: 1590314, description: "Total bytes used" }), + totalMB: z.number().openapi({ example: 1.52, description: "Total megabytes used" }), + totalGB: z.number().openapi({ example: 0, description: "Total gigabytes used" }), + limitGB: z.number().openapi({ example: 1, description: "Storage limit in GB" }), + usagePercent: z.number().openapi({ example: 0, description: "Percentage used" }), + isOverLimit: z.boolean().openapi({ example: false, description: "Is over limit" }), + }) + .openapi("StorageUsage"); + +export const noteUsageSchema = z + .object({ + count: z.number().openapi({ example: 54, description: "Current note count" }), + limit: z.number().openapi({ example: 1000, description: "Maximum notes allowed" }), + usagePercent: z.number().openapi({ example: 5.4, description: "Percentage of limit used" }), + isOverLimit: z.boolean().openapi({ example: false, description: "Is over limit" }), + }) + .openapi("NoteUsage"); + +export const usageSchema = z + .object({ + storage: storageUsageSchema, + notes: noteUsageSchema, + }) + .openapi("Usage"); + +export const userWithUsageSchema = userSchema + .extend({ + usage: usageSchema, + }) + .openapi("UserWithUsage"); + +export const meQuerySchema = z.object({ + include_usage: z + .enum(["true", "false"]) + .optional() + .openapi({ + param: { name: "include_usage", in: "query" }, + example: "true", + description: "Include usage statistics", + }), +}); + +export const deleteUserResponseSchema = z + .object({ + message: z + .string() + .openapi({ example: "User account deleted successfully", description: "Success message" }), + }) + .openapi("DeleteUserResponse"); + +// Folder schemas (for nested objects in notes) +export const folderSchema = z + .object({ + id: z + .string() + .uuid() + .openapi({ example: "123e4567-e89b-12d3-a456-426614174000", description: "Folder ID" }), + userId: z.string().openapi({ example: "user_2abc123", description: "User ID" }), + name: z.string().openapi({ example: "Work", description: "Folder name" }), + color: z.string().nullable().openapi({ example: "#6b7280", description: "Folder color" }), + parentId: z + .string() + .uuid() + .nullable() + .openapi({ example: null, description: "Parent folder ID" }), + sortOrder: z.number().openapi({ example: 0, description: "Sort order" }), + isDefault: z.boolean().nullable().openapi({ example: false, description: "Is default folder" }), + createdAt: z + .string() + .datetime() + .openapi({ example: "2025-01-01T00:00:00.000Z", description: "Created date" }), + updatedAt: z + .string() + .datetime() + .openapi({ example: "2025-01-01T00:00:00.000Z", description: "Updated date" }), + }) + .openapi("Folder"); + +// Note schemas +const countsObjectSchema = z.object({ + all: z.number().openapi({ example: 10, description: "Active notes count" }), + starred: z.number().openapi({ example: 2, description: "Starred notes count" }), + archived: z.number().openapi({ example: 1, description: "Archived notes count" }), + trash: z.number().openapi({ example: 0, description: "Deleted notes count" }), +}); + +export const noteCountsSchema = z + .object({ + all: z.number().openapi({ example: 42, description: "Total active notes count" }), + starred: z.number().openapi({ example: 5, description: "Total starred notes count" }), + archived: z.number().openapi({ example: 12, description: "Total archived notes count" }), + trash: z.number().openapi({ example: 3, description: "Total deleted notes count" }), + folders: z.record(z.string(), countsObjectSchema).openapi({ + example: { + "123e4567-e89b-12d3-a456-426614174000": { all: 10, starred: 2, archived: 1, trash: 0 }, + "223e4567-e89b-12d3-a456-426614174001": { all: 5, starred: 1, archived: 0, trash: 0 }, + }, + description: + "Counts for each root-level folder (folder IDs as keys, includes descendant notes)", + }), + }) + .openapi("NoteCounts"); + +export const folderCountsSchema = z.record(z.string(), countsObjectSchema).openapi("FolderCounts"); + +export const countsQueryParamsSchema = z.object({ + folder_id: z + .string() + .uuid() + .optional() + .openapi({ + param: { name: "folder_id", in: "query" }, + example: "123e4567-e89b-12d3-a456-426614174000", + description: + "Optional. Get counts for each direct child folder of this folder ID (includes descendant notes). If omitted, returns total counts plus root-level folder counts", + }), +}); + +export const noteSchema = z + .object({ + id: z + .string() + .uuid() + .openapi({ example: "123e4567-e89b-12d3-a456-426614174001", description: "Note ID" }), + userId: z.string().openapi({ example: "user_2abc123", description: "User ID" }), + folderId: z + .string() + .uuid() + .nullable() + .openapi({ example: "123e4567-e89b-12d3-a456-426614174000", description: "Folder ID" }), + title: z.string().openapi({ example: "[ENCRYPTED]", description: "Encrypted note title" }), + content: z.string().openapi({ example: "[ENCRYPTED]", description: "Encrypted note content" }), + encryptedTitle: z + .string() + .nullable() + .openapi({ example: "base64_encrypted_data", description: "Encrypted title data" }), + encryptedContent: z + .string() + .nullable() + .openapi({ example: "base64_encrypted_data", description: "Encrypted content data" }), + iv: z.string().nullable().openapi({ + example: "initialization_vector", + description: "Initialization vector for AES-GCM", + }), + salt: z + .string() + .nullable() + .openapi({ example: "salt_value", description: "Salt for key derivation" }), + starred: z.boolean().nullable().openapi({ example: false, description: "Is starred" }), + archived: z.boolean().nullable().openapi({ example: false, description: "Is archived" }), + deleted: z + .boolean() + .nullable() + .openapi({ example: false, description: "Is deleted (in trash)" }), + hidden: z.boolean().nullable().openapi({ example: false, description: "Is hidden" }), + hiddenAt: z + .string() + .datetime() + .nullable() + .openapi({ example: null, description: "When note was hidden" }), + tags: z.array(z.string()).openapi({ example: ["work", "urgent"], description: "Note tags" }), + createdAt: z + .string() + .datetime() + .openapi({ example: "2025-01-01T00:00:00.000Z", description: "Created date" }), + updatedAt: z + .string() + .datetime() + .openapi({ example: "2025-01-01T00:00:00.000Z", description: "Updated date" }), + folder: folderSchema.nullable().optional().openapi({ description: "Associated folder" }), + }) + .openapi("Note"); + +export const noteWithAttachmentCountSchema = noteSchema + .extend({ + attachmentCount: z.number().openapi({ example: 2, description: "Number of file attachments" }), + }) + .openapi("NoteWithAttachmentCount"); + +export const createNoteRequestSchema = z + .object({ + title: z + .string() + .optional() + .openapi({ example: "[ENCRYPTED]", description: "Must be '[ENCRYPTED]'" }), + content: z + .string() + .optional() + .openapi({ example: "[ENCRYPTED]", description: "Must be '[ENCRYPTED]'" }), + folderId: z.string().uuid().nullable().optional().openapi({ + example: null, + description: "Folder ID (use null or empty string for root level, or a valid folder UUID)", + }), + starred: z.boolean().optional().openapi({ example: false, description: "Is starred" }), + tags: z + .array(z.string().max(50)) + .max(20) + .optional() + .openapi({ example: ["work"], description: "Up to 20 tags, max 50 chars each" }), + encryptedTitle: z + .string() + .optional() + .openapi({ example: "base64_encrypted_data", description: "Encrypted title" }), + encryptedContent: z + .string() + .optional() + .openapi({ example: "base64_encrypted_data", description: "Encrypted content" }), + iv: z + .string() + .optional() + .openapi({ example: "initialization_vector", description: "Initialization vector" }), + salt: z.string().optional().openapi({ example: "salt_value", description: "Salt value" }), + }) + .openapi("CreateNoteRequest"); + +export const updateNoteRequestSchema = z + .object({ + title: z + .string() + .optional() + .openapi({ example: "[ENCRYPTED]", description: "Must be '[ENCRYPTED]'" }), + content: z + .string() + .optional() + .openapi({ example: "[ENCRYPTED]", description: "Must be '[ENCRYPTED]'" }), + folderId: z.string().uuid().nullable().optional().openapi({ + example: null, + description: "Folder ID (use null or empty string for root level, or a valid folder UUID)", + }), + starred: z.boolean().optional().openapi({ example: false, description: "Is starred" }), + archived: z.boolean().optional().openapi({ example: false, description: "Is archived" }), + deleted: z.boolean().optional().openapi({ example: false, description: "Is deleted" }), + hidden: z.boolean().optional().openapi({ example: false, description: "Is hidden" }), + tags: z + .array(z.string().max(50)) + .max(20) + .optional() + .openapi({ example: ["work"], description: "Up to 20 tags" }), + encryptedTitle: z + .string() + .optional() + .openapi({ example: "base64_encrypted_data", description: "Encrypted title" }), + encryptedContent: z + .string() + .optional() + .openapi({ example: "base64_encrypted_data", description: "Encrypted content" }), + iv: z + .string() + .optional() + .openapi({ example: "initialization_vector", description: "Initialization vector" }), + salt: z.string().optional().openapi({ example: "salt_value", description: "Salt value" }), + }) + .openapi("UpdateNoteRequest"); + +export const notesQueryParamsSchema = z + .object({ + folderId: z + .string() + .uuid() + .optional() + .openapi({ + param: { name: "folderId", in: "query" }, + example: "123e4567-e89b-12d3-a456-426614174000", + description: "Filter by folder ID", + }), + starred: z + .enum(["true", "false"]) + .optional() + .openapi({ + param: { name: "starred", in: "query" }, + example: "true", + description: "Filter by starred status", + }), + archived: z + .enum(["true", "false"]) + .optional() + .openapi({ + param: { name: "archived", in: "query" }, + example: "false", + description: "Filter by archived status", + }), + deleted: z + .enum(["true", "false"]) + .optional() + .openapi({ + param: { name: "deleted", in: "query" }, + example: "false", + description: "Filter by deleted status", + }), + hidden: z + .enum(["true", "false"]) + .optional() + .openapi({ + param: { name: "hidden", in: "query" }, + example: "false", + description: "Filter by hidden status", + }), + search: z + .string() + .max(100) + .optional() + .openapi({ + param: { name: "search", in: "query" }, + example: "meeting notes", + description: "Search in title and content (max 100 chars, alphanumeric only)", + }), + page: z.coerce + .number() + .min(1) + .optional() + .openapi({ + param: { name: "page", in: "query" }, + example: "1", + description: "Page number (default: 1)", + }), + limit: z.coerce + .number() + .min(1) + .max(50) + .optional() + .openapi({ + param: { name: "limit", in: "query" }, + example: "20", + description: "Items per page (1-50, default: 20)", + }), + }) + .openapi("NotesQueryParams"); + +export const paginationSchema = z + .object({ + page: z.number().openapi({ example: 1, description: "Current page" }), + limit: z.number().openapi({ example: 20, description: "Items per page" }), + total: z.number().openapi({ example: 100, description: "Total items" }), + pages: z.number().openapi({ example: 5, description: "Total pages" }), + }) + .openapi("Pagination"); + +export const notesListResponseSchema = z + .object({ + notes: z + .array(noteWithAttachmentCountSchema) + .openapi({ description: "Array of notes with attachment counts" }), + pagination: paginationSchema, + }) + .openapi("NotesListResponse"); + +export const emptyTrashResponseSchema = z + .object({ + success: z.boolean().openapi({ example: true, description: "Operation success" }), + deletedCount: z + .number() + .openapi({ example: 5, description: "Number of notes permanently deleted" }), + message: z.string().openapi({ + example: "5 notes permanently deleted from trash", + description: "Success message", + }), + }) + .openapi("EmptyTrashResponse"); + +export const noteIdParamSchema = z.object({ + id: z + .string() + .uuid() + .openapi({ + param: { name: "id", in: "path" }, + example: "123e4567-e89b-12d3-a456-426614174001", + description: "Note ID", + }), +}); + +// File attachment schemas +export const fileAttachmentSchema = z + .object({ + id: z + .string() + .uuid() + .openapi({ example: "123e4567-e89b-12d3-a456-426614174002", description: "File ID" }), + noteId: z + .string() + .uuid() + .openapi({ example: "123e4567-e89b-12d3-a456-426614174001", description: "Note ID" }), + filename: z.string().openapi({ + example: "550e8400-e29b-41d4-a716-446655440000_1234567890", + description: "Unique filename", + }), + originalName: z.string().openapi({ example: "document.pdf", description: "Original filename" }), + mimeType: z.string().openapi({ example: "application/pdf", description: "File MIME type" }), + size: z.number().openapi({ example: 1024000, description: "File size in bytes" }), + uploadedAt: z + .string() + .datetime() + .openapi({ example: "2025-01-01T00:00:00.000Z", description: "Upload date" }), + }) + .openapi("FileAttachment"); + +export const fileWithEncryptedDataSchema = z + .object({ + encryptedData: z + .string() + .openapi({ example: "base64_encrypted_file_data", description: "Encrypted file data" }), + iv: z.string().openapi({ + example: "initialization_vector", + description: "Initialization vector for AES-GCM", + }), + salt: z.string().openapi({ example: "salt_value", description: "Salt for key derivation" }), + mimeType: z.string().openapi({ example: "application/pdf", description: "File MIME type" }), + originalName: z.string().openapi({ example: "document.pdf", description: "Original filename" }), + noteSalt: z + .string() + .nullable() + .openapi({ example: "note_salt_value", description: "Note's salt (for decryption)" }), + }) + .openapi("FileWithEncryptedData"); + +export const uploadFileRequestSchema = z + .object({ + originalName: z + .string() + .min(1) + .max(255) + .openapi({ example: "document.pdf", description: "Original filename (1-255 chars)" }), + mimeType: z + .string() + .min(1) + .max(100) + .openapi({ example: "application/pdf", description: "File MIME type (max 100 chars)" }), + size: z + .number() + .int() + .positive() + .openapi({ example: 1024000, description: "File size in bytes (must be positive)" }), + encryptedData: z.string().min(1).openapi({ + example: "base64_encrypted_file_data", + description: "Encrypted file data (base64)", + }), + iv: z + .string() + .min(1) + .openapi({ example: "initialization_vector", description: "Initialization vector" }), + salt: z + .string() + .min(1) + .openapi({ example: "salt_value", description: "Salt for key derivation" }), + }) + .openapi("UploadFileRequest"); + +export const fileIdParamSchema = z.object({ + fileId: z + .string() + .uuid() + .openapi({ + param: { name: "fileId", in: "path" }, + example: "123e4567-e89b-12d3-a456-426614174002", + description: "File ID", + }), +}); + +export const noteIdForFilesParamSchema = z.object({ + noteId: z + .string() + .uuid() + .openapi({ + param: { name: "noteId", in: "path" }, + example: "123e4567-e89b-12d3-a456-426614174001", + description: "Note ID", + }), +}); + +// Code execution schemas +export const executeCodeRequestSchema = z + .object({ + language_id: z.number().int().min(1).max(200).openapi({ + example: 71, + description: "Language ID (e.g., 71 for Python 3, 63 for JavaScript)", + }), + source_code: z.string().min(1).max(50000).openapi({ + example: 'print("Hello, World!")', + description: "Source code to execute (max 50,000 chars)", + }), + stdin: z.string().max(10000).optional().openapi({ + example: "", + description: "Standard input for the program (max 10,000 chars)", + }), + cpu_time_limit: z + .number() + .min(1) + .max(30) + .optional() + .openapi({ example: 5, description: "CPU time limit in seconds (1-30, default: 5)" }), + memory_limit: z.number().min(16384).max(512000).optional().openapi({ + example: 128000, + description: "Memory limit in KB (16384-512000, default: 128000)", + }), + wall_time_limit: z + .number() + .min(1) + .max(60) + .optional() + .openapi({ example: 10, description: "Wall time limit in seconds (1-60, default: 10)" }), + }) + .openapi("ExecuteCodeRequest"); + +export const codeSubmissionResponseSchema = z + .object({ + token: z.string().openapi({ + example: "d85cd024-1548-4165-96c7-7bc88673f194", + description: "Submission token", + }), + }) + .openapi("CodeSubmissionResponse"); + +export const codeExecutionStatusSchema = z + .object({ + stdout: z + .string() + .nullable() + .openapi({ example: "Hello, World!\n", description: "Standard output" }), + stderr: z.string().nullable().openapi({ example: null, description: "Standard error" }), + compile_output: z + .string() + .nullable() + .openapi({ example: null, description: "Compilation output" }), + message: z.string().nullable().openapi({ example: null, description: "Execution message" }), + status: z + .object({ + id: z.number().openapi({ example: 3, description: "Status ID" }), + description: z.string().openapi({ example: "Accepted", description: "Status description" }), + }) + .openapi({ description: "Execution status" }), + time: z + .string() + .nullable() + .openapi({ example: "0.01", description: "Execution time in seconds" }), + memory: z.number().nullable().openapi({ example: 3456, description: "Memory used in KB" }), + token: z.string().openapi({ + example: "d85cd024-1548-4165-96c7-7bc88673f194", + description: "Submission token", + }), + }) + .openapi("CodeExecutionStatus"); + +export const languageSchema = z + .object({ + id: z.number().openapi({ example: 71, description: "Language ID" }), + name: z + .string() + .openapi({ example: "Python (3.8.1)", description: "Language name and version" }), + }) + .openapi("Language"); + +export const codeHealthResponseSchema = z + .object({ + status: z + .enum(["healthy", "degraded", "unhealthy"]) + .openapi({ example: "healthy", description: "Service health status" }), + judge0: z + .enum(["connected", "partial_connectivity", "disconnected"]) + .openapi({ example: "connected", description: "Judge0 connection status" }), + timestamp: z + .string() + .datetime() + .openapi({ example: "2025-01-01T00:00:00.000Z", description: "Timestamp" }), + }) + .openapi("CodeHealthResponse"); + +export const tokenParamSchema = z.object({ + token: z + .string() + .min(1) + .openapi({ + param: { name: "token", in: "path" }, + example: "d85cd024-1548-4165-96c7-7bc88673f194", + description: "Submission token", + }), +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 2bd0029..0970b40 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -39,7 +39,10 @@ export const createNoteSchema = z.object({ .string() .refine((value) => value === "[ENCRYPTED]", "Content must be '[ENCRYPTED]'") .optional(), - folderId: z.string().uuid().nullable().optional(), + folderId: z.preprocess( + (val) => (val === "" ? null : val), + z.string().uuid().nullable().optional() + ), starred: z.boolean().optional(), tags: z.array(z.string().max(50)).max(20).optional(), @@ -58,7 +61,10 @@ export const updateNoteSchema = z.object({ .string() .refine((value) => value === "[ENCRYPTED]", "Content must be '[ENCRYPTED]'") .optional(), - folderId: z.string().uuid().nullable().optional(), + folderId: z.preprocess( + (val) => (val === "" ? null : val), + z.string().uuid().nullable().optional() + ), starred: z.boolean().optional(), archived: z.boolean().optional(), deleted: z.boolean().optional(), diff --git a/src/middleware/security.ts b/src/middleware/security.ts index a0927a9..f2eca82 100644 --- a/src/middleware/security.ts +++ b/src/middleware/security.ts @@ -3,12 +3,17 @@ import { Context, Next } from "hono"; export const securityHeaders = async (c: Context, next: Next): Promise => { await next(); + // Relax CSP for /docs endpoint (Swagger UI) + const isDocsEndpoint = c.req.path === "/docs"; + // Content Security Policy c.res.headers.set( "Content-Security-Policy", "default-src 'self'; " + - "script-src 'self'; " + - "style-src 'self' 'unsafe-inline'; " + + (isDocsEndpoint + ? "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + : "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; ") + "img-src 'self' data: https:; " + "font-src 'self'; " + "connect-src 'self'; " + diff --git a/src/routes/code.ts b/src/routes/code/crud.ts similarity index 56% rename from src/routes/code.ts rename to src/routes/code/crud.ts index abddff8..746c0f2 100644 --- a/src/routes/code.ts +++ b/src/routes/code/crud.ts @@ -1,10 +1,17 @@ -import { Hono } from "hono"; -import { zValidator } from "@hono/zod-validator"; +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; -import { z } from "zod"; -import { logger } from "../lib/logger"; - -const codeRouter = new Hono(); +import { z } from "@hono/zod-openapi"; +import { logger } from "../../lib/logger"; +import { + executeCodeRequestSchema, + codeSubmissionResponseSchema, + codeExecutionStatusSchema, + languageSchema, + codeHealthResponseSchema, + tokenParamSchema, +} from "../../lib/openapi-schemas"; + +const crudRouter = new OpenAPIHono(); const JUDGE0_API_URL = process.env.JUDGE0_API_URL || "https://judge0-ce.p.rapidapi.com"; const JUDGE0_API_KEY = process.env.JUDGE0_API_KEY; @@ -15,25 +22,10 @@ if (!JUDGE0_API_KEY) { process.exit(1); } -const executeCodeSchema = z.object({ - language_id: z.number().int().min(1).max(200), - source_code: z.string().min(1).max(50000), - stdin: z.string().max(10000).optional().default(""), - cpu_time_limit: z.number().min(1).max(30).optional().default(5), - memory_limit: z.number().min(16384).max(512000).optional().default(128000), - wall_time_limit: z.number().min(1).max(60).optional().default(10), -}); - -const tokenSchema = z.object({ - token: z.string().min(1), -}); - async function makeJudge0Request(endpoint: string, options: RequestInit = {}) { const url = `${JUDGE0_API_URL}${endpoint}`; const start = Date.now(); - // Judge0 API call timing - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); @@ -42,7 +34,7 @@ async function makeJudge0Request(endpoint: string, options: RequestInit = {}) { ...options, signal: controller.signal, headers: { - "X-RapidAPI-Key": JUDGE0_API_KEY, + "X-RapidAPI-Key": JUDGE0_API_KEY!, "X-RapidAPI-Host": JUDGE0_API_HOST, "Content-Type": "application/json", ...options.headers, @@ -65,11 +57,11 @@ async function makeJudge0Request(endpoint: string, options: RequestInit = {}) { status: response.status, statusText: response.statusText, endpoint, - errorBody: errorBody.substring(0, 200), // Limit error body length + errorBody: errorBody.substring(0, 200), }); let clientMessage = "Code execution failed. Please try again."; - let statusCode = response.status; + let statusCode: number = response.status; if (response.status === 429) { clientMessage = @@ -84,8 +76,7 @@ async function makeJudge0Request(endpoint: string, options: RequestInit = {}) { statusCode = 503; } - // noinspection ExceptionCaughtLocallyJS - throw new HTTPException(statusCode, { + throw new HTTPException(statusCode as any, { message: clientMessage, }); } @@ -98,7 +89,7 @@ async function makeJudge0Request(endpoint: string, options: RequestInit = {}) { throw error; } - if (error.name === "AbortError") { + if (error instanceof Error && error.name === "AbortError") { logger.error("Judge0 API timeout", { endpoint }); throw new HTTPException(504, { message: "Code execution timed out. Please try again.", @@ -119,7 +110,52 @@ async function makeJudge0Request(endpoint: string, options: RequestInit = {}) { } } -codeRouter.post("/execute", zValidator("json", executeCodeSchema), async (c) => { +// POST /api/code/execute - Execute code +const executeCodeRoute = createRoute({ + method: "post", + path: "/execute", + summary: "Execute code", + description: + "Submit code for execution via Judge0. Returns a token that can be used to check execution status. Supports 50+ programming languages.", + tags: ["Code Execution"], + request: { + body: { + content: { + "application/json": { + schema: executeCodeRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Code submitted successfully", + content: { + "application/json": { + schema: codeSubmissionResponseSchema, + }, + }, + }, + 400: { + description: "Invalid request body", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 429: { + description: "Rate limit exceeded - Too many requests", + }, + 503: { + description: "Code execution service temporarily unavailable", + }, + 504: { + description: "Request timeout - Code execution took too long", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(executeCodeRoute, async (c) => { try { const body = c.req.valid("json"); @@ -154,7 +190,43 @@ codeRouter.post("/execute", zValidator("json", executeCodeSchema), async (c) => } }); -codeRouter.get("/status/:token", zValidator("param", tokenSchema), async (c) => { +// GET /api/code/status/:token - Get execution status +const getStatusRoute = createRoute({ + method: "get", + path: "/status/{token}", + summary: "Get execution status", + description: + "Check the status of a code execution submission. Returns stdout, stderr, execution time, memory usage, and status information.", + tags: ["Code Execution"], + request: { + params: tokenParamSchema, + }, + responses: { + 200: { + description: "Execution status retrieved successfully", + content: { + "application/json": { + schema: codeExecutionStatusSchema, + }, + }, + }, + 400: { + description: "Invalid token format", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Submission not found", + }, + 503: { + description: "Code execution service temporarily unavailable", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(getStatusRoute, async (c) => { try { const { token } = c.req.valid("param"); @@ -194,7 +266,34 @@ codeRouter.get("/status/:token", zValidator("param", tokenSchema), async (c) => } }); -codeRouter.get("/languages", async (c) => { +// GET /api/code/languages - Get supported languages +const getLanguagesRoute = createRoute({ + method: "get", + path: "/languages", + summary: "Get supported languages", + description: + "Returns a list of all programming languages supported by the code execution service, including language IDs and versions.", + tags: ["Code Execution"], + responses: { + 200: { + description: "Languages retrieved successfully", + content: { + "application/json": { + schema: z.array(languageSchema), + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 503: { + description: "Code execution service temporarily unavailable", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(getLanguagesRoute, async (c) => { try { const response = await makeJudge0Request("/languages"); const result = await response.json(); @@ -217,7 +316,46 @@ codeRouter.get("/languages", async (c) => { } }); -codeRouter.get("/health", async (c) => { +// GET /api/code/health - Health check +const getHealthRoute = createRoute({ + method: "get", + path: "/health", + summary: "Health check", + description: "Check the health status of the code execution service and Judge0 connection.", + tags: ["Code Execution"], + responses: { + 200: { + description: "Service is healthy", + content: { + "application/json": { + schema: codeHealthResponseSchema, + }, + }, + }, + 207: { + description: "Service is degraded but partially functional", + content: { + "application/json": { + schema: codeHealthResponseSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 503: { + description: "Service is unhealthy", + content: { + "application/json": { + schema: codeHealthResponseSchema, + }, + }, + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(getHealthRoute, async (c) => { try { const response = await makeJudge0Request("/languages"); @@ -235,7 +373,7 @@ codeRouter.get("/health", async (c) => { timestamp: new Date().toISOString(), }, 207 - ); // Multi-status + ); } } catch (error) { logger.error( @@ -256,4 +394,4 @@ codeRouter.get("/health", async (c) => { } }); -export default codeRouter; +export default crudRouter; diff --git a/src/routes/files.ts b/src/routes/files.ts deleted file mode 100644 index 8490ce6..0000000 --- a/src/routes/files.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Hono } from "hono"; -import { zValidator } from "@hono/zod-validator"; -import { HTTPException } from "hono/http-exception"; -import { db, notes, fileAttachments } from "../db"; -import { eq, and, sql } from "drizzle-orm"; -import { randomUUID } from "crypto"; -import { uploadFileSchema } from "../lib/validation"; -import { checkStorageLimits } from "../middleware/usage"; - -const filesRouter = new Hono(); - -const maxFileSize = process.env.MAX_FILE_SIZE_MB ? parseInt(process.env.MAX_FILE_SIZE_MB) : 50; -const maxNoteSize = process.env.MAX_NOTE_SIZE_MB ? parseInt(process.env.MAX_NOTE_SIZE_MB) : 1024; - -filesRouter.post("/notes/:noteId/files", zValidator("json", uploadFileSchema), async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("noteId"); - const data = c.req.valid("json"); - - await checkStorageLimits(data.size)(c, async () => {}); - - const note = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!note) { - throw new HTTPException(403, { message: "Access denied" }); - } - - const maxFileSizeBytes = maxFileSize * 1024 * 1024; - if (data.size > maxFileSizeBytes) { - throw new HTTPException(413, { - message: `File too large. Maximum size is ${maxFileSize}MB`, - }); - } - - // noinspection SqlNoDataSourceInspection - const result = await db - .select({ totalSize: sql`COALESCE(SUM(size), 0)` }) - .from(fileAttachments) - .where(eq(fileAttachments.noteId, noteId)); - - const totalSize = Number(result[0]?.totalSize || 0); - const newFileSize = Number(data.size); - const combinedSize = totalSize + newFileSize; - const maxNoteSizeBytes = maxNoteSize * 1024 * 1024; - - if (combinedSize > maxNoteSizeBytes) { - throw new HTTPException(413, { - message: `Total attachment size for this note would exceed ${maxNoteSize}MB limit`, - }); - } - - const filename = `${randomUUID()}_${Date.now()}`; - - const [newAttachment] = await db - .insert(fileAttachments) - .values({ - noteId, - filename, - originalName: data.originalName, - mimeType: data.mimeType, - size: data.size, - encryptedData: data.encryptedData, - iv: data.iv, - salt: data.salt, - }) - .returning({ - id: fileAttachments.id, - noteId: fileAttachments.noteId, - filename: fileAttachments.filename, - originalName: fileAttachments.originalName, - mimeType: fileAttachments.mimeType, - size: fileAttachments.size, - uploadedAt: fileAttachments.uploadedAt, - }); - - return c.json(newAttachment, 201); -}); - -filesRouter.get("/files/:fileId", async (c) => { - const userId = c.get("userId"); - const fileId = c.req.param("fileId"); - - const file = await db - .select({ - encryptedData: fileAttachments.encryptedData, - iv: fileAttachments.iv, - salt: fileAttachments.salt, - mimeType: fileAttachments.mimeType, - originalName: fileAttachments.originalName, - noteSalt: notes.salt, // Include note's salt in case frontend needs it - }) - .from(fileAttachments) - .innerJoin(notes, eq(fileAttachments.noteId, notes.id)) - .where(and(eq(fileAttachments.id, fileId), eq(notes.userId, userId))) - .limit(1); - - if (!file || file.length === 0) { - throw new HTTPException(404, { message: "File not found" }); - } - - return c.json(file[0]); -}); - -filesRouter.delete("/files/:fileId", async (c) => { - const userId = c.get("userId"); - const fileId = c.req.param("fileId"); - - const file = await db - .select({ id: fileAttachments.id }) - .from(fileAttachments) - .innerJoin(notes, eq(fileAttachments.noteId, notes.id)) - .where(and(eq(fileAttachments.id, fileId), eq(notes.userId, userId))) - .limit(1); - - if (!file || file.length === 0) { - throw new HTTPException(404, { message: "File not found" }); - } - - await db.delete(fileAttachments).where(eq(fileAttachments.id, fileId)); - - return c.body(null, 204); -}); - -filesRouter.get("/notes/:noteId/files", async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("noteId"); - - const note = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!note) { - throw new HTTPException(403, { message: "Access denied" }); - } - - const attachments = await db - .select({ - id: fileAttachments.id, - noteId: fileAttachments.noteId, - filename: fileAttachments.filename, - originalName: fileAttachments.originalName, - mimeType: fileAttachments.mimeType, - size: fileAttachments.size, - uploadedAt: fileAttachments.uploadedAt, - }) - .from(fileAttachments) - .where(eq(fileAttachments.noteId, noteId)) - .orderBy(fileAttachments.uploadedAt); - - return c.json(attachments); -}); - -export default filesRouter; diff --git a/src/routes/files/crud.ts b/src/routes/files/crud.ts new file mode 100644 index 0000000..571b694 --- /dev/null +++ b/src/routes/files/crud.ts @@ -0,0 +1,292 @@ +import { OpenAPIHono, createRoute, RouteHandler } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { db, notes, fileAttachments } from "../../db"; +import { eq, and, sql } from "drizzle-orm"; +import { randomUUID } from "crypto"; +import { uploadFileSchema } from "../../lib/validation"; +import { checkStorageLimits } from "../../middleware/usage"; +import { + fileAttachmentSchema, + fileWithEncryptedDataSchema, + uploadFileRequestSchema, + fileIdParamSchema, + noteIdForFilesParamSchema, +} from "../../lib/openapi-schemas"; +import { z } from "@hono/zod-openapi"; + +const crudRouter = new OpenAPIHono(); + +const maxFileSize = process.env.MAX_FILE_SIZE_MB ? parseInt(process.env.MAX_FILE_SIZE_MB) : 50; +const maxNoteSize = process.env.MAX_NOTE_SIZE_MB ? parseInt(process.env.MAX_NOTE_SIZE_MB) : 1024; + +// POST /api/notes/:noteId/files - Upload a file attachment to a note +const uploadFileRoute = createRoute({ + method: "post", + path: "/notes/{noteId}/files", + summary: "Upload file", + description: `Upload an encrypted file attachment to a note. Maximum file size: ${maxFileSize}MB. Maximum total attachments per note: ${maxNoteSize}MB`, + tags: ["Files"], + request: { + params: noteIdForFilesParamSchema, + body: { + content: { + "application/json": { + schema: uploadFileRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: "File uploaded successfully", + content: { + "application/json": { + schema: fileAttachmentSchema, + }, + }, + }, + 400: { + description: "Invalid request body", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 403: { + description: "Access denied - Note not found or not owned by user", + }, + 413: { + description: "File too large or total attachment size limit exceeded", + }, + }, + security: [{ Bearer: [] }], +}); + +const uploadFileHandler: RouteHandler = async (c) => { + const userId = c.get("userId"); + const { noteId } = c.req.valid("param"); + + // Validate with the original schema that has the refinements + const rawData = await c.req.json(); + const data = uploadFileSchema.parse(rawData); + + await checkStorageLimits(data.size)(c, async () => {}); + + const note = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!note) { + throw new HTTPException(403, { message: "Access denied" }); + } + + const maxFileSizeBytes = maxFileSize * 1024 * 1024; + if (data.size > maxFileSizeBytes) { + throw new HTTPException(413, { + message: `File too large. Maximum size is ${maxFileSize}MB`, + }); + } + + // noinspection SqlNoDataSourceInspection + const result = await db + .select({ totalSize: sql`COALESCE(SUM(size), 0)` }) + .from(fileAttachments) + .where(eq(fileAttachments.noteId, noteId)); + + const totalSize = Number(result[0]?.totalSize || 0); + const newFileSize = Number(data.size); + const combinedSize = totalSize + newFileSize; + const maxNoteSizeBytes = maxNoteSize * 1024 * 1024; + + if (combinedSize > maxNoteSizeBytes) { + throw new HTTPException(413, { + message: `Total attachment size for this note would exceed ${maxNoteSize}MB limit`, + }); + } + + const filename = `${randomUUID()}_${Date.now()}`; + + const [newAttachment] = await db + .insert(fileAttachments) + .values({ + noteId, + filename, + originalName: data.originalName, + mimeType: data.mimeType, + size: data.size, + encryptedData: data.encryptedData, + iv: data.iv, + salt: data.salt, + }) + .returning({ + id: fileAttachments.id, + noteId: fileAttachments.noteId, + filename: fileAttachments.filename, + originalName: fileAttachments.originalName, + mimeType: fileAttachments.mimeType, + size: fileAttachments.size, + uploadedAt: fileAttachments.uploadedAt, + }); + + return c.json(newAttachment, 201); +}; + +crudRouter.openapi(uploadFileRoute, uploadFileHandler); + +// GET /api/notes/:noteId/files - List all file attachments for a note +const listFilesRoute = createRoute({ + method: "get", + path: "/notes/{noteId}/files", + summary: "List files", + description: "Get all file attachments for a specific note", + tags: ["Files"], + request: { + params: noteIdForFilesParamSchema, + }, + responses: { + 200: { + description: "Files retrieved successfully", + content: { + "application/json": { + schema: z.array(fileAttachmentSchema), + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 403: { + description: "Access denied - Note not found or not owned by user", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(listFilesRoute, async (c) => { + const userId = c.get("userId"); + const { noteId } = c.req.valid("param"); + + const note = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!note) { + throw new HTTPException(403, { message: "Access denied" }); + } + + const attachments = await db + .select({ + id: fileAttachments.id, + noteId: fileAttachments.noteId, + filename: fileAttachments.filename, + originalName: fileAttachments.originalName, + mimeType: fileAttachments.mimeType, + size: fileAttachments.size, + uploadedAt: fileAttachments.uploadedAt, + }) + .from(fileAttachments) + .where(eq(fileAttachments.noteId, noteId)) + .orderBy(fileAttachments.uploadedAt); + + return c.json(attachments); +}); + +// GET /api/files/:fileId - Get a single file attachment +const getFileRoute = createRoute({ + method: "get", + path: "/files/{fileId}", + summary: "Get file", + description: + "Retrieve an encrypted file attachment by ID. Returns the encrypted data with decryption metadata", + tags: ["Files"], + request: { + params: fileIdParamSchema, + }, + responses: { + 200: { + description: "File retrieved successfully", + content: { + "application/json": { + schema: fileWithEncryptedDataSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "File not found or access denied", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(getFileRoute, async (c) => { + const userId = c.get("userId"); + const { fileId } = c.req.valid("param"); + + const file = await db + .select({ + encryptedData: fileAttachments.encryptedData, + iv: fileAttachments.iv, + salt: fileAttachments.salt, + mimeType: fileAttachments.mimeType, + originalName: fileAttachments.originalName, + noteSalt: notes.salt, // Include note's salt in case frontend needs it + }) + .from(fileAttachments) + .innerJoin(notes, eq(fileAttachments.noteId, notes.id)) + .where(and(eq(fileAttachments.id, fileId), eq(notes.userId, userId))) + .limit(1); + + if (!file || file.length === 0) { + throw new HTTPException(404, { message: "File not found" }); + } + + return c.json(file[0]); +}); + +// DELETE /api/files/:fileId - Delete a file attachment +const deleteFileRoute = createRoute({ + method: "delete", + path: "/files/{fileId}", + summary: "Delete file", + description: "Permanently delete a file attachment", + tags: ["Files"], + request: { + params: fileIdParamSchema, + }, + responses: { + 204: { + description: "File deleted successfully", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "File not found or access denied", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(deleteFileRoute, async (c) => { + const userId = c.get("userId"); + const { fileId } = c.req.valid("param"); + + const file = await db + .select({ id: fileAttachments.id }) + .from(fileAttachments) + .innerJoin(notes, eq(fileAttachments.noteId, notes.id)) + .where(and(eq(fileAttachments.id, fileId), eq(notes.userId, userId))) + .limit(1); + + if (!file || file.length === 0) { + throw new HTTPException(404, { message: "File not found" }); + } + + await db.delete(fileAttachments).where(eq(fileAttachments.id, fileId)); + + return c.body(null, 204); +}); + +export default crudRouter; diff --git a/src/routes/folders/actions.ts b/src/routes/folders/actions.ts new file mode 100644 index 0000000..3e93454 --- /dev/null +++ b/src/routes/folders/actions.ts @@ -0,0 +1,87 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { HTTPException } from "hono/http-exception"; +import { db, folders } from "../../db"; +import { reorderFolderSchema } from "../../lib/validation"; +import { eq, and, desc, asc, isNull } from "drizzle-orm"; +import { deleteCache } from "../../lib/cache"; +import { CacheKeys } from "../../lib/cache-keys"; + +const actionsRouter = new Hono(); + +// PUT /api/folders/:id/reorder - Reorder folders +actionsRouter.put("/:id/reorder", zValidator("json", reorderFolderSchema), async (c) => { + const userId = c.get("userId"); + const folderId = c.req.param("id"); + const { newIndex } = c.req.valid("json"); + + // Check if folder exists and belongs to user + const folderToMove = await db.query.folders.findFirst({ + where: and(eq(folders.id, folderId), eq(folders.userId, userId)), + }); + + if (!folderToMove) { + throw new HTTPException(404, { message: "Folder not found" }); + } + + // Get all folders in the same parent scope (same parentId) for this user + const siblingFolders = await db.query.folders.findMany({ + where: and( + eq(folders.userId, userId), + folderToMove.parentId ? eq(folders.parentId, folderToMove.parentId) : isNull(folders.parentId) + ), + orderBy: [asc(folders.sortOrder), desc(folders.createdAt)], + }); + + // Validate newIndex + if (newIndex < 0 || newIndex >= siblingFolders.length) { + throw new HTTPException(400, { message: "Invalid new index" }); + } + + // Find current position of the folder + const currentIndex = siblingFolders.findIndex((folder) => folder.id === folderId); + if (currentIndex === -1) { + throw new HTTPException(404, { message: "Folder not found in siblings" }); + } + + // If already in correct position, no need to do anything + if (currentIndex === newIndex) { + return c.json({ message: "Folder already in correct position" }); + } + + try { + // Use a transaction to ensure consistency + await db.transaction(async (tx) => { + // Create a new array with the folder moved to the new position + const reorderedFolders = [...siblingFolders]; + const [movedFolder] = reorderedFolders.splice(currentIndex, 1); + reorderedFolders.splice(newIndex, 0, movedFolder); + + // Update sort order for all affected folders + const updatePromises = reorderedFolders.map((folder, index) => + tx + .update(folders) + .set({ + sortOrder: index, + updatedAt: new Date(), + }) + .where(eq(folders.id, folder.id)) + ); + + await Promise.all(updatePromises); + }); + + // Invalidate cache + await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId)); + + return c.json({ + message: "Folder reordered successfully", + folderId, + newIndex, + }); + } catch { + throw new HTTPException(500, { message: "Failed to reorder folders" }); + } +}); + +export default actionsRouter; diff --git a/src/routes/folders.ts b/src/routes/folders/crud.ts similarity index 64% rename from src/routes/folders.ts rename to src/routes/folders/crud.ts index 93f8f9c..60cf7c5 100644 --- a/src/routes/folders.ts +++ b/src/routes/folders/crud.ts @@ -1,21 +1,17 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { HTTPException } from "hono/http-exception"; -import { db, folders, notes } from "../db"; -import { - createFolderSchema, - updateFolderSchema, - foldersQuerySchema, - reorderFolderSchema, -} from "../lib/validation"; +import { db, folders, notes } from "../../db"; +import { createFolderSchema, updateFolderSchema, foldersQuerySchema } from "../../lib/validation"; import { eq, and, desc, count, asc, isNull } from "drizzle-orm"; -import { getCache, setCache, deleteCache } from "../lib/cache"; -import { CacheKeys, CacheTTL } from "../lib/cache-keys"; -import { logger } from "../lib/logger"; +import { getCache, setCache, deleteCache } from "../../lib/cache"; +import { CacheKeys, CacheTTL } from "../../lib/cache-keys"; +import { logger } from "../../lib/logger"; -const foldersRouter = new Hono(); +const crudRouter = new Hono(); -foldersRouter.get("/", zValidator("query", foldersQuerySchema), async (c) => { +// GET /api/folders - List folders with pagination and filtering +crudRouter.get("/", zValidator("query", foldersQuerySchema), async (c) => { const userId = c.get("userId"); const query = c.req.valid("query"); @@ -41,7 +37,6 @@ foldersRouter.get("/", zValidator("query", foldersQuerySchema), async (c) => { const offset = (query.page - 1) * query.limit; const userFolders = await db.query.folders.findMany({ where: whereClause, - // Order by user's preferred order (sortOrder), then by creation date as fallback orderBy: [asc(folders.sortOrder), desc(folders.createdAt)], limit: query.limit, offset, @@ -50,7 +45,6 @@ foldersRouter.get("/", zValidator("query", foldersQuerySchema), async (c) => { where: and(eq(notes.deleted, false), eq(notes.archived, false)), }, children: { - // Also order children by sortOrder orderBy: [asc(folders.sortOrder), desc(folders.createdAt)], }, }, @@ -59,7 +53,7 @@ foldersRouter.get("/", zValidator("query", foldersQuerySchema), async (c) => { const foldersWithCounts = userFolders.map((folder) => ({ ...folder, noteCount: folder.notes.length, - notes: undefined, // Remove notes from response to keep it clean + notes: undefined, })); const result = { @@ -81,7 +75,8 @@ foldersRouter.get("/", zValidator("query", foldersQuerySchema), async (c) => { return c.json(result); }); -foldersRouter.get("/:id", async (c) => { +// GET /api/folders/:id - Get a single folder +crudRouter.get("/:id", async (c) => { const userId = c.get("userId"); const folderId = c.req.param("id"); @@ -108,7 +103,8 @@ foldersRouter.get("/:id", async (c) => { }); }); -foldersRouter.post("/", zValidator("json", createFolderSchema), async (c) => { +// POST /api/folders - Create a new folder +crudRouter.post("/", zValidator("json", createFolderSchema), async (c) => { const userId = c.get("userId"); const data = c.req.valid("json"); @@ -150,7 +146,8 @@ foldersRouter.post("/", zValidator("json", createFolderSchema), async (c) => { return c.json(newFolder, 201); }); -foldersRouter.put("/:id", zValidator("json", updateFolderSchema), async (c) => { +// PUT /api/folders/:id - Update a folder +crudRouter.put("/:id", zValidator("json", updateFolderSchema), async (c) => { const userId = c.get("userId"); const folderId = c.req.param("id"); const data = c.req.valid("json"); @@ -197,81 +194,8 @@ foldersRouter.put("/:id", zValidator("json", updateFolderSchema), async (c) => { return c.json(updatedFolder); }); -foldersRouter.put("/:id/reorder", zValidator("json", reorderFolderSchema), async (c) => { - const userId = c.get("userId"); - const folderId = c.req.param("id"); - const { newIndex } = c.req.valid("json"); - - // Check if folder exists and belongs to user - const folderToMove = await db.query.folders.findFirst({ - where: and(eq(folders.id, folderId), eq(folders.userId, userId)), - }); - - if (!folderToMove) { - throw new HTTPException(404, { message: "Folder not found" }); - } - - // Get all folders in the same parent scope (same parentId) for this user - const siblingFolders = await db.query.folders.findMany({ - where: and( - eq(folders.userId, userId), - folderToMove.parentId ? eq(folders.parentId, folderToMove.parentId) : isNull(folders.parentId) - ), - orderBy: [asc(folders.sortOrder), desc(folders.createdAt)], - }); - - // Validate newIndex - if (newIndex < 0 || newIndex >= siblingFolders.length) { - throw new HTTPException(400, { message: "Invalid new index" }); - } - - // Find current position of the folder - const currentIndex = siblingFolders.findIndex((folder) => folder.id === folderId); - if (currentIndex === -1) { - throw new HTTPException(404, { message: "Folder not found in siblings" }); - } - - // If already in correct position, no need to do anything - if (currentIndex === newIndex) { - return c.json({ message: "Folder already in correct position" }); - } - - try { - // Use a transaction to ensure consistency - await db.transaction(async (tx) => { - // Create a new array with the folder moved to the new position - const reorderedFolders = [...siblingFolders]; - const [movedFolder] = reorderedFolders.splice(currentIndex, 1); - reorderedFolders.splice(newIndex, 0, movedFolder); - - // Update sort order for all affected folders - const updatePromises = reorderedFolders.map((folder, index) => - tx - .update(folders) - .set({ - sortOrder: index, - updatedAt: new Date(), - }) - .where(eq(folders.id, folder.id)) - ); - - await Promise.all(updatePromises); - }); - - // Invalidate cache - await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId)); - - return c.json({ - message: "Folder reordered successfully", - folderId, - newIndex, - }); - } catch { - throw new HTTPException(500, { message: "Failed to reorder folders" }); - } -}); - -foldersRouter.delete("/:id", async (c) => { +// DELETE /api/folders/:id - Delete a folder +crudRouter.delete("/:id", async (c) => { const userId = c.get("userId"); const folderId = c.req.param("id"); @@ -309,12 +233,7 @@ foldersRouter.delete("/:id", async (c) => { try { await db.transaction(async (tx) => { // Delete the folder - const deleteResult = await tx.delete(folders).where(eq(folders.id, folderId)); - - // Verify deletion succeeded - if (deleteResult.rowCount === 0) { - throw new Error(`Folder ${folderId} was not found or could not be deleted`); - } + await tx.delete(folders).where(eq(folders.id, folderId)); // Reorder remaining folders to fill the gap const remainingFolders = await tx.query.folders.findMany({ @@ -333,13 +252,7 @@ foldersRouter.delete("/:id", async (c) => { tx.update(folders).set({ sortOrder: index }).where(eq(folders.id, folder.id)) ); - const updateResults = await Promise.all(updatePromises); - - // Verify all updates succeeded - const failedUpdates = updateResults.filter((result) => result.rowCount === 0); - if (failedUpdates.length > 0) { - throw new Error(`Failed to reorder ${failedUpdates.length} folder(s) after deletion`); - } + await Promise.all(updatePromises); } }); @@ -364,4 +277,4 @@ foldersRouter.delete("/:id", async (c) => { } }); -export default foldersRouter; +export default crudRouter; diff --git a/src/routes/metrics.ts b/src/routes/metrics.ts index f8faef5..34f29a6 100644 --- a/src/routes/metrics.ts +++ b/src/routes/metrics.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, Context } from "hono"; import { logger } from "../lib/logger"; const metricsRouter = new Hono(); @@ -35,7 +35,7 @@ interface HealthStatus { } // Enhanced health check with more detailed status -metricsRouter.get("/health", async (c) => { +metricsRouter.get("/health", async (c: Context) => { const startTime = Date.now(); const memUsage = process.memoryUsage(); const environment = process.env.NODE_ENV || "development"; @@ -98,7 +98,7 @@ metricsRouter.get("/health", async (c) => { }); // System metrics endpoint for monitoring dashboards -metricsRouter.get("/metrics", async (c) => { +metricsRouter.get("/metrics", async (c: Context) => { const startTime = Date.now(); const memUsage = process.memoryUsage(); const cpuUsage = process.cpuUsage(); @@ -121,7 +121,7 @@ metricsRouter.get("/metrics", async (c) => { }); // Readiness probe for Kubernetes/ECS -metricsRouter.get("/ready", async (c) => { +metricsRouter.get("/ready", async (c: Context) => { // Simple readiness check - service is ready if it can respond return c.json({ status: "ready", @@ -130,7 +130,7 @@ metricsRouter.get("/ready", async (c) => { }); // Liveness probe for Kubernetes/ECS -metricsRouter.get("/live", async (c) => { +metricsRouter.get("/live", async (c: Context) => { // Simple liveness check - service is alive if it can respond return c.json({ status: "alive", diff --git a/src/routes/notes.ts b/src/routes/notes.ts deleted file mode 100644 index 362db26..0000000 --- a/src/routes/notes.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { Hono } from "hono"; -import { zValidator } from "@hono/zod-validator"; -import { HTTPException } from "hono/http-exception"; -import { db, notes } from "../db"; -import { createNoteSchema, updateNoteSchema, notesQuerySchema } from "../lib/validation"; -import { eq, and, desc, or, ilike, count, SQL } from "drizzle-orm"; -import { checkNoteLimits } from "../middleware/usage"; -import { getCache, setCache } from "../lib/cache"; -import { CacheKeys, CacheTTL } from "../lib/cache-keys"; - -const notesRouter = new Hono(); - -notesRouter.get("/", zValidator("query", notesQuerySchema), async (c) => { - const userId = c.get("userId"); - const query = c.req.valid("query"); - - const conditions: SQL[] = [eq(notes.userId, userId)]; - - if (query.folderId !== undefined) { - conditions.push(eq(notes.folderId, query.folderId)); - } - - if (query.starred !== undefined) { - conditions.push(eq(notes.starred, query.starred)); - } - - if (query.archived !== undefined) { - conditions.push(eq(notes.archived, query.archived)); - } - - if (query.deleted !== undefined) { - conditions.push(eq(notes.deleted, query.deleted)); - } - - if (query.hidden !== undefined) { - conditions.push(eq(notes.hidden, query.hidden)); - } - - if (query.search) { - const escapedSearch = query.search - .replace(/\\/g, "\\\\") - .replace(/%/g, "\\%") - .replace(/_/g, "\\_"); - - conditions.push( - or(ilike(notes.title, `%${escapedSearch}%`), ilike(notes.content, `%${escapedSearch}%`))! - ); - } - - const whereClause = conditions.length > 1 ? and(...conditions) : conditions[0]; - - const [{ total }] = await db.select({ total: count() }).from(notes).where(whereClause); - - const offset = (query.page - 1) * query.limit; - const userNotes = await db.query.notes.findMany({ - where: whereClause, - orderBy: [desc(notes.updatedAt)], - limit: query.limit, - offset, - with: { - folder: true, - attachments: true, - }, - }); - - // Add attachmentCount to each note and remove full attachments array - const notesWithAttachmentCount = userNotes.map((note) => ({ - ...note, - attachmentCount: note.attachments.length, - attachments: undefined, - })); - - return c.json({ - notes: notesWithAttachmentCount, - pagination: { - page: query.page, - limit: query.limit, - total, - pages: Math.ceil(total / query.limit), - }, - }); -}); - -notesRouter.get("/counts", async (c) => { - const userId = c.get("userId"); - const cacheKey = CacheKeys.notesCounts(userId); - - // Try to get from cache first - const cachedCounts = await getCache<{ - all: number; - starred: number; - archived: number; - trash: number; - }>(cacheKey); - - if (cachedCounts) { - return c.json(cachedCounts); - } - - // If not in cache, query the database - const [allCount] = await db - .select({ total: count() }) - .from(notes) - .where(and(eq(notes.userId, userId), eq(notes.deleted, false), eq(notes.archived, false))); - - const [starredCount] = await db - .select({ total: count() }) - .from(notes) - .where( - and( - eq(notes.userId, userId), - eq(notes.starred, true), - eq(notes.deleted, false), - eq(notes.archived, false) - ) - ); - - const [archivedCount] = await db - .select({ total: count() }) - .from(notes) - .where(and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.deleted, false))); - - const [trashCount] = await db - .select({ total: count() }) - .from(notes) - .where(and(eq(notes.userId, userId), eq(notes.deleted, true))); - - const counts = { - all: allCount.total, - starred: starredCount.total, - archived: archivedCount.total, - trash: trashCount.total, - }; - - // Cache the results - await setCache(cacheKey, counts, CacheTTL.notesCounts); - - return c.json(counts); -}); - -notesRouter.get("/:id", async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("id"); - - const note = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - with: { - folder: true, - }, - }); - - if (!note) { - throw new HTTPException(404, { message: "Note not found" }); - } - - return c.json(note); -}); - -notesRouter.post("/", checkNoteLimits, zValidator("json", createNoteSchema), async (c) => { - const userId = c.get("userId"); - const data = c.req.valid("json"); - - const [newNote] = await db - .insert(notes) - .values({ - ...data, - userId, - }) - .returning(); - - return c.json(newNote, 201); -}); - -notesRouter.put("/:id", zValidator("json", updateNoteSchema), async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("id"); - const data = c.req.valid("json"); - - const existingNote = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!existingNote) { - throw new HTTPException(404, { message: "Note not found" }); - } - - const [updatedNote] = await db - .update(notes) - .set({ - ...data, - updatedAt: new Date(), - }) - .where(eq(notes.id, noteId)) - .returning(); - - return c.json(updatedNote); -}); - -notesRouter.delete("/empty-trash", async (c) => { - try { - const userId = c.get("userId"); - - const [{ total }] = await db - .select({ total: count() }) - .from(notes) - .where(and(eq(notes.userId, userId), eq(notes.deleted, true))); - - await db.delete(notes).where(and(eq(notes.userId, userId), eq(notes.deleted, true))); - - return c.json({ - success: true, - deletedCount: total, - message: `${total} notes permanently deleted from trash`, - }); - } catch { - throw new HTTPException(500, { - message: "Failed to empty trash. Please try again.", - }); - } -}); - -notesRouter.delete("/:id", async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("id"); - - const existingNote = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!existingNote) { - throw new HTTPException(404, { message: "Note not found" }); - } - - const [deletedNote] = await db - .update(notes) - .set({ - deleted: true, - updatedAt: new Date(), - }) - .where(eq(notes.id, noteId)) - .returning(); - - return c.json(deletedNote); -}); - -notesRouter.post("/:id/star", async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("id"); - - const existingNote = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!existingNote) { - throw new HTTPException(404, { message: "Note not found" }); - } - - const [updatedNote] = await db - .update(notes) - .set({ - starred: !existingNote.starred, - updatedAt: new Date(), - }) - .where(eq(notes.id, noteId)) - .returning(); - - return c.json(updatedNote); -}); - -notesRouter.post("/:id/restore", async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("id"); - - const existingNote = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!existingNote) { - throw new HTTPException(404, { message: "Note not found" }); - } - - const [restoredNote] = await db - .update(notes) - .set({ - deleted: false, - archived: false, - updatedAt: new Date(), - }) - .where(eq(notes.id, noteId)) - .returning(); - - return c.json(restoredNote); -}); - -notesRouter.post("/:id/hide", async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("id"); - - const existingNote = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!existingNote) { - throw new HTTPException(404, { message: "Note not found" }); - } - - if (existingNote.hidden) { - throw new HTTPException(400, { message: "Note is already hidden" }); - } - - const [hiddenNote] = await db - .update(notes) - .set({ - hidden: true, - hiddenAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(notes.id, noteId)) - .returning(); - - return c.json(hiddenNote); -}); - -notesRouter.post("/:id/unhide", async (c) => { - const userId = c.get("userId"); - const noteId = c.req.param("id"); - - const existingNote = await db.query.notes.findFirst({ - where: and(eq(notes.id, noteId), eq(notes.userId, userId)), - }); - - if (!existingNote) { - throw new HTTPException(404, { message: "Note not found" }); - } - - if (!existingNote.hidden) { - throw new HTTPException(400, { message: "Note is not hidden" }); - } - - const [unhiddenNote] = await db - .update(notes) - .set({ - hidden: false, - hiddenAt: null, - updatedAt: new Date(), - }) - .where(eq(notes.id, noteId)) - .returning(); - - return c.json(unhiddenNote); -}); - -export default notesRouter; diff --git a/src/routes/notes/actions.ts b/src/routes/notes/actions.ts new file mode 100644 index 0000000..99ee5c8 --- /dev/null +++ b/src/routes/notes/actions.ts @@ -0,0 +1,238 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { db, notes } from "../../db"; +import { eq, and } from "drizzle-orm"; +import { noteSchema, noteIdParamSchema } from "../../lib/openapi-schemas"; + +const actionsRouter = new OpenAPIHono(); + +// POST /api/notes/:id/star - Toggle star status +const starNoteRoute = createRoute({ + method: "post", + path: "/{id}/star", + summary: "Toggle star", + description: "Toggle the starred status of a note", + tags: ["Notes"], + request: { + params: noteIdParamSchema, + }, + responses: { + 200: { + description: "Note starred status toggled successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Note not found", + }, + }, + security: [{ Bearer: [] }], +}); + +actionsRouter.openapi(starNoteRoute, async (c) => { + const userId = c.get("userId"); + const { id: noteId } = c.req.valid("param"); + + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!existingNote) { + throw new HTTPException(404, { message: "Note not found" }); + } + + const [updatedNote] = await db + .update(notes) + .set({ + starred: !existingNote.starred, + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return c.json(updatedNote, 200); +}); + +// POST /api/notes/:id/restore - Restore note from trash +const restoreNoteRoute = createRoute({ + method: "post", + path: "/{id}/restore", + summary: "Restore note", + description: "Restore a deleted note from trash and unarchive it", + tags: ["Notes"], + request: { + params: noteIdParamSchema, + }, + responses: { + 200: { + description: "Note restored successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Note not found", + }, + }, + security: [{ Bearer: [] }], +}); + +actionsRouter.openapi(restoreNoteRoute, async (c) => { + const userId = c.get("userId"); + const { id: noteId } = c.req.valid("param"); + + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!existingNote) { + throw new HTTPException(404, { message: "Note not found" }); + } + + const [restoredNote] = await db + .update(notes) + .set({ + deleted: false, + archived: false, + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return c.json(restoredNote, 200); +}); + +// POST /api/notes/:id/hide - Hide a note +const hideNoteRoute = createRoute({ + method: "post", + path: "/{id}/hide", + summary: "Hide note", + description: "Hide a note from normal view", + tags: ["Notes"], + request: { + params: noteIdParamSchema, + }, + responses: { + 200: { + description: "Note hidden successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 400: { + description: "Note is already hidden", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Note not found", + }, + }, + security: [{ Bearer: [] }], +}); + +actionsRouter.openapi(hideNoteRoute, async (c) => { + const userId = c.get("userId"); + const { id: noteId } = c.req.valid("param"); + + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!existingNote) { + throw new HTTPException(404, { message: "Note not found" }); + } + + if (existingNote.hidden) { + throw new HTTPException(400, { message: "Note is already hidden" }); + } + + const [hiddenNote] = await db + .update(notes) + .set({ + hidden: true, + hiddenAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return c.json(hiddenNote, 200); +}); + +// POST /api/notes/:id/unhide - Unhide a note +const unhideNoteRoute = createRoute({ + method: "post", + path: "/{id}/unhide", + summary: "Unhide note", + description: "Unhide a previously hidden note", + tags: ["Notes"], + request: { + params: noteIdParamSchema, + }, + responses: { + 200: { + description: "Note unhidden successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 400: { + description: "Note is not hidden", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Note not found", + }, + }, + security: [{ Bearer: [] }], +}); + +actionsRouter.openapi(unhideNoteRoute, async (c) => { + const userId = c.get("userId"); + const { id: noteId } = c.req.valid("param"); + + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!existingNote) { + throw new HTTPException(404, { message: "Note not found" }); + } + + if (!existingNote.hidden) { + throw new HTTPException(400, { message: "Note is not hidden" }); + } + + const [unhiddenNote] = await db + .update(notes) + .set({ + hidden: false, + hiddenAt: null, + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return c.json(unhiddenNote, 200); +}); + +export default actionsRouter; diff --git a/src/routes/notes/counts.ts b/src/routes/notes/counts.ts new file mode 100644 index 0000000..51c132b --- /dev/null +++ b/src/routes/notes/counts.ts @@ -0,0 +1,389 @@ +import { OpenAPIHono, createRoute, RouteHandler } from "@hono/zod-openapi"; +import { db, notes, folders } from "../../db"; +import { eq, and, count, inArray, isNull } from "drizzle-orm"; +import { getCache, setCache } from "../../lib/cache"; +import { CacheKeys, CacheTTL } from "../../lib/cache-keys"; +import { z } from "@hono/zod-openapi"; + +const countsRouter = new OpenAPIHono(); + +// Helper function to recursively get all descendant folder IDs +async function getAllDescendantFolderIds(folderId: string, userId: string): Promise { + const childFolders = await db.query.folders.findMany({ + where: and(eq(folders.parentId, folderId), eq(folders.userId, userId)), + columns: { id: true }, + }); + + if (childFolders.length === 0) { + return [folderId]; + } + + const allIds = [folderId]; + for (const child of childFolders) { + const descendantIds = await getAllDescendantFolderIds(child.id, userId); + allIds.push(...descendantIds); + } + + return allIds; +} + +const getNotesCountsRoute = createRoute({ + method: "get", + path: "", + summary: "Get note counts", + description: + "Returns aggregated counts of notes by category. Without folder_id: returns total counts plus folders object. With folder_id: returns only a folders object (Record) with counts for each direct child folder including their descendants", + tags: ["Notes"], + request: { + query: z.object({ + folder_id: z + .string() + .optional() + .openapi({ + param: { name: "folder_id", in: "query" }, + example: "123e4567-e89b-12d3-a456-426614174000", + description: + "Optional. Get counts for each direct child folder of this folder ID (includes descendant notes). If omitted, returns total counts plus root-level folder counts", + }), + }), + }, + responses: { + 200: { + description: + "Note counts retrieved successfully. Without folder_id: returns {all, starred, archived, trash, folders}. With folder_id: returns Record", + content: { + "application/json": { + schema: z.any().openapi({ + example: { + all: 42, + starred: 5, + archived: 12, + trash: 3, + folders: { + "123e4567-e89b-12d3-a456-426614174000": { + all: 10, + starred: 2, + archived: 1, + trash: 0, + }, + }, + }, + }), + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + }, + security: [{ Bearer: [] }], +}); + +const getNotesCountsHandler: RouteHandler = async (c) => { + const userId = c.get("userId"); + const query = c.req.valid("query"); + const folderId = query.folder_id; + + // If folder_id is provided, get counts for each direct child folder + if (folderId) { + const cacheKey = `notes:${userId}:folder:${folderId}:counts`; + + // Try to get from cache first + const cachedCounts = await getCache< + Record< + string, + { + all: number; + starred: number; + archived: number; + trash: number; + } + > + >(cacheKey); + + if (cachedCounts) { + return c.json(cachedCounts, 200); + } + + // Get direct children of the specified folder + const childFolders = await db.query.folders.findMany({ + where: and(eq(folders.parentId, folderId), eq(folders.userId, userId)), + columns: { id: true }, + }); + + const folderCounts: Record< + string, + { + all: number; + starred: number; + archived: number; + trash: number; + } + > = {}; + + // For each child folder, get all descendant folder IDs and count notes + for (const childFolder of childFolders) { + const allFolderIds = await getAllDescendantFolderIds(childFolder.id, userId); + + const [allCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.deleted, false), + eq(notes.archived, false) + ) + ); + + const [starredCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.starred, true), + eq(notes.deleted, false), + eq(notes.archived, false) + ) + ); + + const [archivedCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.archived, true), + eq(notes.deleted, false) + ) + ); + + const [trashCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.deleted, true) + ) + ); + + folderCounts[childFolder.id] = { + all: allCount.total, + starred: starredCount.total, + archived: archivedCount.total, + trash: trashCount.total, + }; + } + + // Cache the results + await setCache(cacheKey, folderCounts, CacheTTL.notesCounts); + + return c.json(folderCounts, 200); + } + + // Default behavior: get total counts for the user + root folder counts + const cacheKey = CacheKeys.notesCounts(userId); + + // Try to get from cache first + const cachedCounts = await getCache<{ + all: number; + starred: number; + archived: number; + trash: number; + folders: Record< + string, + { + all: number; + starred: number; + archived: number; + trash: number; + } + >; + }>(cacheKey); + + if (cachedCounts) { + return c.json(cachedCounts, 200); + } + + // If not in cache, query the database for total counts + const [allCount] = await db + .select({ total: count() }) + .from(notes) + .where(and(eq(notes.userId, userId), eq(notes.deleted, false), eq(notes.archived, false))); + + const [starredCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + eq(notes.starred, true), + eq(notes.deleted, false), + eq(notes.archived, false) + ) + ); + + const [archivedCount] = await db + .select({ total: count() }) + .from(notes) + .where(and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.deleted, false))); + + const [trashCount] = await db + .select({ total: count() }) + .from(notes) + .where(and(eq(notes.userId, userId), eq(notes.deleted, true))); + + // Get root-level folders (no parent) + const rootFolders = await db.query.folders.findMany({ + where: and(eq(folders.userId, userId), isNull(folders.parentId)), + columns: { id: true }, + }); + + const folderCounts: Record< + string, + { + all: number; + starred: number; + archived: number; + trash: number; + } + > = {}; + + // For each root folder, get all descendant folder IDs and count notes + for (const rootFolder of rootFolders) { + const allFolderIds = await getAllDescendantFolderIds(rootFolder.id, userId); + + const [folderAllCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.deleted, false), + eq(notes.archived, false) + ) + ); + + const [folderStarredCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.starred, true), + eq(notes.deleted, false), + eq(notes.archived, false) + ) + ); + + const [folderArchivedCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.archived, true), + eq(notes.deleted, false) + ) + ); + + const [folderTrashCount] = await db + .select({ total: count() }) + .from(notes) + .where( + and( + eq(notes.userId, userId), + inArray(notes.folderId, allFolderIds), + eq(notes.deleted, true) + ) + ); + + folderCounts[rootFolder.id] = { + all: folderAllCount.total, + starred: folderStarredCount.total, + archived: folderArchivedCount.total, + trash: folderTrashCount.total, + }; + } + + const counts = { + all: allCount.total, + starred: starredCount.total, + archived: archivedCount.total, + trash: trashCount.total, + folders: folderCounts, + }; + + // Cache the results + await setCache(cacheKey, counts, CacheTTL.notesCounts); + + return c.json(counts, 200); +}; + +countsRouter.openapi(getNotesCountsRoute, getNotesCountsHandler); + +// Also register with trailing slash (Swagger UI adds trailing slashes) +const getNotesCountsRouteSlash = createRoute({ + method: "get", + path: "/", + summary: "Get note counts", + description: + "Returns aggregated counts of notes by category. Without folder_id: returns total counts plus folders object. With folder_id: returns only a folders object (Record) with counts for each direct child folder including their descendants", + tags: ["Notes"], + request: { + query: z.object({ + folder_id: z + .string() + .optional() + .openapi({ + param: { name: "folder_id", in: "query" }, + example: "123e4567-e89b-12d3-a456-426614174000", + description: + "Optional. Get counts for each direct child folder of this folder ID (includes descendant notes). If omitted, returns total counts plus root-level folder counts", + }), + }), + }, + responses: { + 200: { + description: + "Note counts retrieved successfully. Without folder_id: returns {all, starred, archived, trash, folders}. With folder_id: returns Record", + content: { + "application/json": { + schema: z.any().openapi({ + example: { + all: 42, + starred: 5, + archived: 12, + trash: 3, + folders: { + "123e4567-e89b-12d3-a456-426614174000": { + all: 10, + starred: 2, + archived: 1, + trash: 0, + }, + }, + }, + }), + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + }, + security: [{ Bearer: [] }], +}); + +countsRouter.openapi(getNotesCountsRouteSlash, getNotesCountsHandler); + +export default countsRouter; diff --git a/src/routes/notes/crud.ts b/src/routes/notes/crud.ts new file mode 100644 index 0000000..7e63c86 --- /dev/null +++ b/src/routes/notes/crud.ts @@ -0,0 +1,442 @@ +import { OpenAPIHono, createRoute, RouteHandler } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { db, notes } from "../../db"; +import { createNoteSchema, updateNoteSchema } from "../../lib/validation"; +import { eq, and, desc, or, ilike, count, SQL } from "drizzle-orm"; +import { checkNoteLimits } from "../../middleware/usage"; +import { + noteSchema, + notesListResponseSchema, + createNoteRequestSchema, + updateNoteRequestSchema, + notesQueryParamsSchema, + noteIdParamSchema, +} from "../../lib/openapi-schemas"; + +const crudRouter = new OpenAPIHono(); + +// GET /api/notes - List notes with pagination and filtering +const listNotesRoute = createRoute({ + method: "get", + path: "", + summary: "List notes", + description: + "Get a paginated list of notes with optional filters for folder, starred, archived, deleted, hidden status, and search", + tags: ["Notes"], + request: { + query: notesQueryParamsSchema, + }, + responses: { + 200: { + description: "Notes retrieved successfully", + content: { + "application/json": { + schema: notesListResponseSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + }, + security: [{ Bearer: [] }], +}); + +// Handler function for listing notes +const listNotesHandler: RouteHandler = async (c) => { + const userId = c.get("userId"); + const query = c.req.valid("query"); + + const conditions: SQL[] = [eq(notes.userId, userId)]; + + if (query.folderId !== undefined) { + conditions.push(eq(notes.folderId, query.folderId)); + } + + if (query.starred !== undefined) { + const starred = query.starred === "true"; + conditions.push(eq(notes.starred, starred)); + } + + if (query.archived !== undefined) { + const archived = query.archived === "true"; + conditions.push(eq(notes.archived, archived)); + } + + if (query.deleted !== undefined) { + const deleted = query.deleted === "true"; + conditions.push(eq(notes.deleted, deleted)); + } + + if (query.hidden !== undefined) { + const hidden = query.hidden === "true"; + conditions.push(eq(notes.hidden, hidden)); + } + + if (query.search) { + const escapedSearch = query.search + .replace(/\\/g, "\\\\") + .replace(/%/g, "\\%") + .replace(/_/g, "\\_"); + + conditions.push( + or(ilike(notes.title, `%${escapedSearch}%`), ilike(notes.content, `%${escapedSearch}%`))! + ); + } + + const whereClause = conditions.length > 1 ? and(...conditions) : conditions[0]; + + const [{ total }] = await db.select({ total: count() }).from(notes).where(whereClause); + + const page = query.page || 1; + const limit = query.limit || 20; + const offset = (page - 1) * limit; + + const userNotes = await db.query.notes.findMany({ + where: whereClause, + orderBy: [desc(notes.updatedAt)], + limit, + offset, + with: { + folder: true, + attachments: true, + }, + }); + + // Add attachmentCount to each note and remove full attachments array + const notesWithAttachmentCount = userNotes.map((note) => ({ + ...note, + attachmentCount: note.attachments.length, + attachments: undefined, + })); + + return c.json( + { + notes: notesWithAttachmentCount, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }, + 200 + ); +}; + +crudRouter.openapi(listNotesRoute, listNotesHandler); + +// Also register with trailing slash (Swagger UI adds trailing slashes) +const listNotesRouteSlash = createRoute({ + method: "get", + path: "/", + summary: "List notes", + description: + "Get a paginated list of notes with optional filters for folder, starred, archived, deleted, hidden status, and search", + tags: ["Notes"], + request: { + query: notesQueryParamsSchema, + }, + responses: { + 200: { + description: "Notes retrieved successfully", + content: { + "application/json": { + schema: notesListResponseSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(listNotesRouteSlash, listNotesHandler); + +// GET /api/notes/:id - Get a single note +const getNoteRoute = createRoute({ + method: "get", + path: "/{id}", + summary: "Get note by ID", + description: "Retrieve a single note by its ID with associated folder information", + tags: ["Notes"], + request: { + params: noteIdParamSchema, + }, + responses: { + 200: { + description: "Note retrieved successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Note not found", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(getNoteRoute, async (c) => { + const userId = c.get("userId"); + const { id: noteId } = c.req.valid("param"); + + const note = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + with: { + folder: true, + }, + }); + + if (!note) { + throw new HTTPException(404, { message: "Note not found" }); + } + + return c.json(note, 200); +}); + +// POST /api/notes - Create a new note +const createNoteRoute = createRoute({ + method: "post", + path: "", + summary: "Create note", + description: + "Create a new encrypted note. Title and content must be '[ENCRYPTED]' with actual encrypted data in encryptedTitle/encryptedContent fields", + tags: ["Notes"], + request: { + body: { + content: { + "application/json": { + schema: createNoteRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: "Note created successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 400: { + description: "Invalid request body", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 402: { + description: "Payment required - Note limit exceeded", + }, + }, + security: [{ Bearer: [] }], +}); + +// Handler function for creating notes +const createNoteHandler: RouteHandler = async (c) => { + const userId = c.get("userId"); + + // Validate with the original schema that has the refinements + const data = await c.req.json(); + const validatedData = createNoteSchema.parse(data) as { + title: string; + content: string; + folderId?: string | null; + starred?: boolean; + tags?: string[]; + encryptedTitle?: string; + encryptedContent?: string; + iv?: string; + salt?: string; + }; + + const [newNote] = await db + .insert(notes) + .values({ + ...validatedData, + userId, + }) + .returning(); + + return c.json(newNote, 201); +}; + +// Apply middleware before the route handler +crudRouter.use("/", checkNoteLimits); +crudRouter.use("", checkNoteLimits); + +crudRouter.openapi(createNoteRoute, createNoteHandler); + +// Also register with trailing slash (Swagger UI adds trailing slashes) +const createNoteRouteSlash = createRoute({ + method: "post", + path: "/", + summary: "Create note", + description: + "Create a new encrypted note. Title and content must be '[ENCRYPTED]' with actual encrypted data in encryptedTitle/encryptedContent fields", + tags: ["Notes"], + request: { + body: { + content: { + "application/json": { + schema: createNoteRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: "Note created successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 400: { + description: "Invalid request body", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 402: { + description: "Payment required - Note limit exceeded", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(createNoteRouteSlash, createNoteHandler); + +// PUT /api/notes/:id - Update a note +const updateNoteRoute = createRoute({ + method: "put", + path: "/{id}", + summary: "Update note", + description: + "Update an existing note's properties. Title and content must be '[ENCRYPTED]' if provided", + tags: ["Notes"], + request: { + params: noteIdParamSchema, + body: { + content: { + "application/json": { + schema: updateNoteRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Note updated successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 400: { + description: "Invalid request body", + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Note not found", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(updateNoteRoute, async (c) => { + const userId = c.get("userId"); + const { id: noteId } = c.req.valid("param"); + + // Validate with the original schema that has the refinements + const data = await c.req.json(); + const validatedData = updateNoteSchema.parse(data); + + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!existingNote) { + throw new HTTPException(404, { message: "Note not found" }); + } + + const [updatedNote] = await db + .update(notes) + .set({ + ...validatedData, + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return c.json(updatedNote, 200); +}); + +// DELETE /api/notes/:id - Soft delete a note (move to trash) +const deleteNoteRoute = createRoute({ + method: "delete", + path: "/{id}", + summary: "Delete note", + description: + "Soft delete a note by marking it as deleted (moves to trash). Can be restored later", + tags: ["Notes"], + request: { + params: noteIdParamSchema, + }, + responses: { + 200: { + description: "Note deleted successfully", + content: { + "application/json": { + schema: noteSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 404: { + description: "Note not found", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(deleteNoteRoute, async (c) => { + const userId = c.get("userId"); + const { id: noteId } = c.req.valid("param"); + + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)), + }); + + if (!existingNote) { + throw new HTTPException(404, { message: "Note not found" }); + } + + const [deletedNote] = await db + .update(notes) + .set({ + deleted: true, + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return c.json(deletedNote, 200); +}); + +export default crudRouter; diff --git a/src/routes/notes/trash.ts b/src/routes/notes/trash.ts new file mode 100644 index 0000000..f040090 --- /dev/null +++ b/src/routes/notes/trash.ts @@ -0,0 +1,62 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; +import { db, notes } from "../../db"; +import { eq, and, count } from "drizzle-orm"; +import { emptyTrashResponseSchema } from "../../lib/openapi-schemas"; + +const trashRouter = new OpenAPIHono(); + +// DELETE /api/notes/empty-trash - Permanently delete all trashed notes +const emptyTrashRoute = createRoute({ + method: "delete", + path: "/empty-trash", + summary: "Empty trash", + description: + "Permanently delete all notes marked as deleted (in trash). This action cannot be undone", + tags: ["Notes"], + responses: { + 200: { + description: "Trash emptied successfully", + content: { + "application/json": { + schema: emptyTrashResponseSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + 500: { + description: "Failed to empty trash", + }, + }, + security: [{ Bearer: [] }], +}); + +trashRouter.openapi(emptyTrashRoute, async (c) => { + try { + const userId = c.get("userId"); + + const [{ total }] = await db + .select({ total: count() }) + .from(notes) + .where(and(eq(notes.userId, userId), eq(notes.deleted, true))); + + await db.delete(notes).where(and(eq(notes.userId, userId), eq(notes.deleted, true))); + + return c.json( + { + success: true, + deletedCount: total, + message: `${total} notes permanently deleted from trash`, + }, + 200 + ); + } catch { + throw new HTTPException(500, { + message: "Failed to empty trash. Please try again.", + }); + } +}); + +export default trashRouter; diff --git a/src/routes/users.ts b/src/routes/users.ts deleted file mode 100644 index 5a26439..0000000 --- a/src/routes/users.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Hono } from "hono"; -import { getCurrentUser } from "../middleware/auth"; - -const usersRouter = new Hono(); - -usersRouter.get("/me", async (c) => { - const user = getCurrentUser(c); - const includeUsage = c.req.query("include_usage") === "true"; - - if (!includeUsage) { - return c.json(user); - } - - const FREE_TIER_STORAGE_GB = process.env.FREE_TIER_STORAGE_GB - ? parseFloat(process.env.FREE_TIER_STORAGE_GB) - : 1; - const FREE_TIER_NOTE_LIMIT = process.env.FREE_TIER_NOTE_LIMIT - ? parseInt(process.env.FREE_TIER_NOTE_LIMIT) - : 1000; - - const { db, fileAttachments, notes } = await import("../db"); - const { eq, and, sum, count, isNull, or } = await import("drizzle-orm"); - - const storageResult = await db - .select({ - totalBytes: sum(fileAttachments.size), - }) - .from(fileAttachments) - .innerJoin(notes, eq(fileAttachments.noteId, notes.id)) - .where( - and( - eq(notes.userId, user.id), - or(isNull(notes.deleted), eq(notes.deleted, false)) // Only count files from non-deleted notes - ) - ); - - const noteCountResult = await db - .select({ - count: count(), - }) - .from(notes) - .where(and(eq(notes.userId, user.id), or(isNull(notes.deleted), eq(notes.deleted, false)))); - - const totalBytes = storageResult[0]?.totalBytes ? Number(storageResult[0].totalBytes) : 0; - const totalMB = Math.round((totalBytes / (1024 * 1024)) * 100) / 100; - const totalGB = Math.round((totalMB / 1024) * 100) / 100; - const noteCount = noteCountResult[0]?.count || 0; - - const storageUsagePercent = Math.round((totalGB / FREE_TIER_STORAGE_GB) * 100 * 100) / 100; - const noteUsagePercent = Math.round((noteCount / FREE_TIER_NOTE_LIMIT) * 100 * 100) / 100; - - return c.json({ - ...user, - usage: { - storage: { - totalBytes, - totalMB, - totalGB, - limitGB: FREE_TIER_STORAGE_GB, - usagePercent: storageUsagePercent, - isOverLimit: totalGB > FREE_TIER_STORAGE_GB, - }, - notes: { - count: noteCount, - limit: FREE_TIER_NOTE_LIMIT, - usagePercent: noteUsagePercent, - isOverLimit: noteCount > FREE_TIER_NOTE_LIMIT, - }, - }, - }); -}); - -usersRouter.delete("/me", async (c) => { - const { db, users } = await import("../db"); - const { eq } = await import("drizzle-orm"); - - const userId = c.get("userId"); - - await db.delete(users).where(eq(users.id, userId)); - - return c.json({ message: "User account deleted successfully" }); -}); - -export default usersRouter; diff --git a/src/routes/users/crud.ts b/src/routes/users/crud.ts new file mode 100644 index 0000000..b1c383b --- /dev/null +++ b/src/routes/users/crud.ts @@ -0,0 +1,136 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { getCurrentUser } from "../../middleware/auth"; +import { + userWithUsageSchema, + meQuerySchema, + deleteUserResponseSchema, +} from "../../lib/openapi-schemas"; +import { db, fileAttachments, notes, users } from "../../db"; +import { eq, and, sum, count, isNull, or } from "drizzle-orm"; + +const crudRouter = new OpenAPIHono(); + +// GET /api/users/me - Get current user +const getMeRoute = createRoute({ + method: "get", + path: "/me", + summary: "Get current user", + description: + "Returns the authenticated user's information. When include_usage=true, includes storage and note usage statistics", + tags: ["Users"], + request: { + query: meQuerySchema, + }, + responses: { + 200: { + description: + "User information retrieved successfully. Returns user with usage when include_usage=true, otherwise just user info", + content: { + "application/json": { + schema: userWithUsageSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(getMeRoute, async (c) => { + const user = getCurrentUser(c); + const query = c.req.valid("query"); + const includeUsage = query.include_usage === "true"; + + if (!includeUsage) { + return c.json(user, 200); + } + + const FREE_TIER_STORAGE_GB = process.env.FREE_TIER_STORAGE_GB + ? parseFloat(process.env.FREE_TIER_STORAGE_GB) + : 1; + const FREE_TIER_NOTE_LIMIT = process.env.FREE_TIER_NOTE_LIMIT + ? parseInt(process.env.FREE_TIER_NOTE_LIMIT) + : 1000; + + const storageResult = await db + .select({ + totalBytes: sum(fileAttachments.size), + }) + .from(fileAttachments) + .innerJoin(notes, eq(fileAttachments.noteId, notes.id)) + .where(and(eq(notes.userId, user.id), or(isNull(notes.deleted), eq(notes.deleted, false)))); + + const noteCountResult = await db + .select({ + count: count(), + }) + .from(notes) + .where(and(eq(notes.userId, user.id), or(isNull(notes.deleted), eq(notes.deleted, false)))); + + const totalBytes = storageResult[0]?.totalBytes ? Number(storageResult[0].totalBytes) : 0; + const totalMB = Math.round((totalBytes / (1024 * 1024)) * 100) / 100; + const totalGB = Math.round((totalMB / 1024) * 100) / 100; + const noteCount = noteCountResult[0]?.count || 0; + + const storageUsagePercent = Math.round((totalGB / FREE_TIER_STORAGE_GB) * 100 * 100) / 100; + const noteUsagePercent = Math.round((noteCount / FREE_TIER_NOTE_LIMIT) * 100 * 100) / 100; + + return c.json( + { + ...user, + usage: { + storage: { + totalBytes, + totalMB, + totalGB, + limitGB: FREE_TIER_STORAGE_GB, + usagePercent: storageUsagePercent, + isOverLimit: totalGB > FREE_TIER_STORAGE_GB, + }, + notes: { + count: noteCount, + limit: FREE_TIER_NOTE_LIMIT, + usagePercent: noteUsagePercent, + isOverLimit: noteCount > FREE_TIER_NOTE_LIMIT, + }, + }, + }, + 200 + ); +}); + +// DELETE /api/users/me - Delete current user +const deleteMeRoute = createRoute({ + method: "delete", + path: "/me", + summary: "Delete current user", + description: + "Permanently deletes the authenticated user's account and all associated data (notes, folders, attachments)", + tags: ["Users"], + responses: { + 200: { + description: "User account deleted successfully", + content: { + "application/json": { + schema: deleteUserResponseSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing authentication", + }, + }, + security: [{ Bearer: [] }], +}); + +crudRouter.openapi(deleteMeRoute, async (c) => { + const userId = c.get("userId"); + + await db.delete(users).where(eq(users.id, userId)); + + return c.json({ message: "User account deleted successfully" }, 200); +}); + +export default crudRouter; diff --git a/src/server.ts b/src/server.ts index a081870..7137c64 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,18 +7,24 @@ const isDevelopment = process.env.NODE_ENV === "development"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { bodyLimit } from "hono/body-limit"; +import { trimTrailingSlash } from "hono/trailing-slash"; import { HTTPException } from "hono/http-exception"; import { createServer } from "http"; +import { swaggerUI } from "@hono/swagger-ui"; import { WebSocketManager } from "./websocket"; import { authMiddleware } from "./middleware/auth"; import { securityHeaders } from "./middleware/security"; import { rateLimit, cleanup as rateLimitCleanup } from "./middleware/rate-limit"; import { closeCache } from "./lib/cache"; -import foldersRouter from "./routes/folders"; -import notesRouter from "./routes/notes"; -import usersRouter from "./routes/users"; -import filesRouter from "./routes/files"; -import codeRouter from "./routes/code"; +import foldersCrudRouter from "./routes/folders/crud"; +import foldersActionsRouter from "./routes/folders/actions"; +import crudRouter from "./routes/notes/crud"; +import actionsRouter from "./routes/notes/actions"; +import trashRouter from "./routes/notes/trash"; +import countsRouter from "./routes/notes/counts"; +import usersRouter from "./routes/users/crud"; +import filesRouter from "./routes/files/crud"; +import codeRouter from "./routes/code/crud"; import metricsRouter from "./routes/metrics"; import { VERSION } from "./version"; import { logger } from "./lib/logger"; @@ -36,7 +42,10 @@ const maxBodySize = Math.ceil(maxFileSize * 1.35); const app = new Hono(); -// Apply security headers first +// Strip trailing slashes from all requests (fixes Swagger UI issue) +app.use("*", trimTrailingSlash()); + +// Apply security headers app.use("*", securityHeaders); // Add request logging middleware @@ -76,7 +85,9 @@ const httpRateLimitMax = process.env.HTTP_RATE_LIMIT_MAX_REQUESTS const fileRateLimitMax = process.env.HTTP_FILE_RATE_LIMIT_MAX ? parseInt(process.env.HTTP_FILE_RATE_LIMIT_MAX) - : 100; + : process.env.NODE_ENV === "development" + ? 1000 + : 100; logger.info("HTTP rate limiting configured", { windowMinutes: httpRateLimitWindow / 1000 / 60, @@ -159,6 +170,151 @@ app.get("/health", (c) => { }); }); +// OpenAPI documentation +app.get( + "/docs", + swaggerUI({ + url: "/api/openapi.json", + persistAuthorization: true, // Save token in browser + }) +); + +// Serve OpenAPI spec +app.get("/api/openapi.json", (c) => { + // Get OpenAPI documents from routers + const usersDoc = (usersRouter as any).getOpenAPIDocument({ + openapi: "3.1.0", + info: { + title: "Typelets API", + version: VERSION, + description: + "A secure, encrypted notes management API with folder organization and file attachments", + contact: { + name: "Typelets API", + url: "https://github.com/typelets/typelets-api", + }, + }, + servers: [ + { + url: process.env.API_URL || "http://localhost:3000", + description: "API Server", + }, + ], + }); + + const countsDoc = (countsRouter as any).getOpenAPIDocument({}); + const crudDoc = (crudRouter as any).getOpenAPIDocument({}); + const actionsDoc = (actionsRouter as any).getOpenAPIDocument({}); + const trashDoc = (trashRouter as any).getOpenAPIDocument({}); + const filesDoc = (filesRouter as any).getOpenAPIDocument({}); + const codeDoc = (codeRouter as any).getOpenAPIDocument({}); + + // Merge paths from all routers into usersDoc + if (!usersDoc.paths) { + usersDoc.paths = {}; + } + + // Prefix users paths with /api/users + const prefixedUsersPaths: any = {}; + Object.keys(usersDoc.paths).forEach((path) => { + prefixedUsersPaths[`/api/users${path}`] = usersDoc.paths[path]; + }); + usersDoc.paths = prefixedUsersPaths; + + // Merge counts paths with /api/notes/counts prefix + if (countsDoc.paths) { + Object.keys(countsDoc.paths).forEach((path) => { + const fullPath = + path === "" || path === "/" ? "/api/notes/counts" : `/api/notes/counts${path}`; + usersDoc.paths[fullPath] = countsDoc.paths[path]; + }); + } + + // Merge crud paths with /api/notes prefix + if (crudDoc.paths) { + Object.keys(crudDoc.paths).forEach((path) => { + const fullPath = path === "" || path === "/" ? "/api/notes" : `/api/notes${path}`; + usersDoc.paths[fullPath] = crudDoc.paths[path]; + }); + } + + // Merge actions paths with /api/notes prefix + if (actionsDoc.paths) { + Object.keys(actionsDoc.paths).forEach((path) => { + const fullPath = path === "" || path === "/" ? "/api/notes" : `/api/notes${path}`; + usersDoc.paths[fullPath] = actionsDoc.paths[path]; + }); + } + + // Merge trash paths with /api/notes prefix + if (trashDoc.paths) { + Object.keys(trashDoc.paths).forEach((path) => { + const fullPath = path === "" || path === "/" ? "/api/notes" : `/api/notes${path}`; + usersDoc.paths[fullPath] = trashDoc.paths[path]; + }); + } + + // Merge files paths with /api prefix + if (filesDoc.paths) { + Object.keys(filesDoc.paths).forEach((path) => { + const fullPath = path === "" || path === "/" ? "/api" : `/api${path}`; + usersDoc.paths[fullPath] = filesDoc.paths[path]; + }); + } + + // Merge code paths with /api/code prefix + if (codeDoc.paths) { + Object.keys(codeDoc.paths).forEach((path) => { + const fullPath = path === "" || path === "/" ? "/api/code" : `/api/code${path}`; + usersDoc.paths[fullPath] = codeDoc.paths[path]; + }); + } + + // Merge schemas from all routers + if (!usersDoc.components) { + usersDoc.components = {}; + } + if (!usersDoc.components.schemas) { + usersDoc.components.schemas = {}; + } + + if (countsDoc.components?.schemas) { + Object.assign(usersDoc.components.schemas, countsDoc.components.schemas); + } + + if (crudDoc.components?.schemas) { + Object.assign(usersDoc.components.schemas, crudDoc.components.schemas); + } + + if (actionsDoc.components?.schemas) { + Object.assign(usersDoc.components.schemas, actionsDoc.components.schemas); + } + + if (trashDoc.components?.schemas) { + Object.assign(usersDoc.components.schemas, trashDoc.components.schemas); + } + + if (filesDoc.components?.schemas) { + Object.assign(usersDoc.components.schemas, filesDoc.components.schemas); + } + + if (codeDoc.components?.schemas) { + Object.assign(usersDoc.components.schemas, codeDoc.components.schemas); + } + + // Manually add securitySchemes to components + usersDoc.components.securitySchemes = { + Bearer: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Clerk authentication token", + }, + }; + + return c.json(usersDoc); +}); + app.get("/websocket/status", (c) => { if (!wsManager) { return c.json({ error: "WebSocket not initialized" }, 500); @@ -201,8 +357,12 @@ app.use( ); app.route("/api/users", usersRouter); -app.route("/api/folders", foldersRouter); -app.route("/api/notes", notesRouter); +app.route("/api/folders", foldersCrudRouter); +app.route("/api/folders", foldersActionsRouter); +app.route("/api/notes/counts", countsRouter); +app.route("/api/notes", trashRouter); // Register trash router before crud to avoid /{id} catching /empty-trash +app.route("/api/notes", crudRouter); +app.route("/api/notes", actionsRouter); app.route("/api/code", codeRouter); app.route("/api", filesRouter); diff --git a/src/version.ts b/src/version.ts index e7d86e0..e416c90 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // This file is automatically updated by semantic-release -export const VERSION = "1.7.2" \ No newline at end of file +export const VERSION = "1.7.2";