diff --git a/drizzle/0001_far_winter_soldier.sql b/drizzle/0001_far_winter_soldier.sql new file mode 100644 index 0000000..98f3afd --- /dev/null +++ b/drizzle/0001_far_winter_soldier.sql @@ -0,0 +1,18 @@ +ALTER TABLE "file_attachments" DROP CONSTRAINT "file_attachments_note_id_fkey"; +--> statement-breakpoint +ALTER TABLE "folders" DROP CONSTRAINT "folders_parent_id_folders_id_fk"; +--> statement-breakpoint +DROP INDEX "idx_file_attachments_note_id";--> statement-breakpoint +ALTER TABLE "file_attachments" ALTER COLUMN "note_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "file_attachments" ALTER COLUMN "uploaded_at" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "notes" ALTER COLUMN "tags" SET DEFAULT '{}';--> statement-breakpoint +ALTER TABLE "notes" ALTER COLUMN "salt" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "notes" ADD COLUMN "type" text DEFAULT 'note' NOT NULL;--> statement-breakpoint +ALTER TABLE "file_attachments" ADD CONSTRAINT "file_attachments_note_id_notes_id_fk" FOREIGN KEY ("note_id") REFERENCES "public"."notes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_folders_user_id" ON "folders" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_folders_user_sort" ON "folders" USING btree ("user_id","sort_order");--> statement-breakpoint +CREATE INDEX "idx_notes_user_id" ON "notes" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_notes_folder_id" ON "notes" USING btree ("folder_id");--> statement-breakpoint +CREATE INDEX "idx_notes_user_updated" ON "notes" USING btree ("user_id","updated_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "idx_notes_type" ON "notes" USING btree ("type");--> statement-breakpoint +CREATE INDEX "idx_file_attachments_note_id" ON "file_attachments" USING btree ("note_id"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..592bf7a --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,531 @@ +{ + "id": "397ee404-7901-4e52-910e-dcf150145a54", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.file_attachments": { + "name": "file_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_title": { + "name": "encrypted_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'encrypted_placeholder'" + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_file_attachments_note_id": { + "name": "idx_file_attachments_note_id", + "columns": [ + { + "expression": "note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_attachments_note_id_notes_id_fk": { + "name": "file_attachments_note_id_notes_id_fk", + "tableFrom": "file_attachments", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folders": { + "name": "folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6b7280'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_folders_user_id": { + "name": "idx_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_folders_user_sort": { + "name": "idx_folders_user_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folders_user_id_users_id_fk": { + "name": "folders_user_id_users_id_fk", + "tableFrom": "folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'note'" + }, + "encrypted_title": { + "name": "encrypted_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_content": { + "name": "encrypted_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starred": { + "name": "starred", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notes_user_id": { + "name": "idx_notes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notes_folder_id": { + "name": "idx_notes_folder_id", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notes_user_updated": { + "name": "idx_notes_user_updated", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notes_type": { + "name": "idx_notes_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_user_id_users_id_fk": { + "name": "notes_user_id_users_id_fk", + "tableFrom": "notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_folder_id_folders_id_fk": { + "name": "notes_folder_id_folders_id_fk", + "tableFrom": "notes", + "tableTo": "folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8f87f3d..90ea23d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1757373470045, "tag": "0000_crazy_medusa", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1761780448921, + "tag": "0001_far_winter_soldier", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index e08ce7e..67a458c 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -170,4 +170,67 @@ describe("Test Infrastructure Integration", () => { expect(await countRows("notes")).toBe(0); }); }); + + describe("Note Types", () => { + it("should create a note with default type 'note'", async () => { + const user = await createTestUser(); + const note = await createTestNote(user.id, null); + + expect(note.type).toBe("note"); + }); + + it("should create a diagram note when type is specified", async () => { + const user = await createTestUser(); + const note = await createTestNote(user.id, null, { + type: "diagram", + title: "UML Diagram", + }); + + expect(note.type).toBe("diagram"); + expect(note.title).toBe("UML Diagram"); + }); + + it("should query notes by type", async () => { + const user = await createTestUser(); + const folder = await createTestFolder(user.id); + + // Create mixed note types + await createTestNote(user.id, folder.id, { type: "note", title: "Regular Note 1" }); + await createTestNote(user.id, folder.id, { type: "diagram", title: "Diagram 1" }); + await createTestNote(user.id, folder.id, { type: "note", title: "Regular Note 2" }); + await createTestNote(user.id, folder.id, { type: "diagram", title: "Diagram 2" }); + + // Query only diagrams + const diagrams = await db.query.notes.findMany({ + where: eq(notes.type, "diagram"), + }); + + expect(diagrams).toHaveLength(2); + expect(diagrams.every((n) => n.type === "diagram")).toBe(true); + + // Query only regular notes + const regularNotes = await db.query.notes.findMany({ + where: eq(notes.type, "note"), + }); + + expect(regularNotes).toHaveLength(2); + expect(regularNotes.every((n) => n.type === "note")).toBe(true); + }); + + it("should update note type from note to diagram", async () => { + const user = await createTestUser(); + const note = await createTestNote(user.id, null, { type: "note" }); + + expect(note.type).toBe("note"); + + // Update to diagram + const [updatedNote] = await db + .update(notes) + .set({ type: "diagram" }) + .where(eq(notes.id, note.id)) + .returning(); + + expect(updatedNote.type).toBe("diagram"); + }); + }); }); diff --git a/src/db/__tests__/schema.test.ts b/src/db/__tests__/schema.test.ts index c5485a1..46b55a8 100644 --- a/src/db/__tests__/schema.test.ts +++ b/src/db/__tests__/schema.test.ts @@ -217,6 +217,7 @@ describe("Database Schema (db/schema.ts)", () => { folderId: "folder-uuid", title: "My Note", content: "Note content", + type: "note", encryptedTitle: null, encryptedContent: null, iv: null, @@ -242,6 +243,7 @@ describe("Database Schema (db/schema.ts)", () => { folderId: null, title: "Unfiled Note", content: "Content", + type: "note", encryptedTitle: null, encryptedContent: null, iv: null, diff --git a/src/db/schema.ts b/src/db/schema.ts index 5ff19de..47c310b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -44,6 +44,9 @@ export const notes = pgTable( title: text("title").notNull(), content: text("content").default(""), + type: text("type", { enum: ["note", "diagram"] }) + .default("note") + .notNull(), encryptedTitle: text("encrypted_title"), encryptedContent: text("encrypted_content"), @@ -64,6 +67,7 @@ export const notes = pgTable( userIdIdx: index("idx_notes_user_id").on(table.userId), folderIdIdx: index("idx_notes_folder_id").on(table.folderId), userUpdatedIdx: index("idx_notes_user_updated").on(table.userId, table.updatedAt.desc()), + typeIdx: index("idx_notes_type").on(table.type), }) ); diff --git a/src/lib/openapi-schemas.ts b/src/lib/openapi-schemas.ts index 7962845..babea37 100644 --- a/src/lib/openapi-schemas.ts +++ b/src/lib/openapi-schemas.ts @@ -305,6 +305,9 @@ export const noteSchema = z .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" }), + type: z + .enum(["note", "diagram"]) + .openapi({ example: "note", description: "Note type: 'note' or 'diagram'" }), encryptedTitle: z .string() .nullable() @@ -372,6 +375,10 @@ export const createNoteRequestSchema = z .max(20) .optional() .openapi({ example: ["work"], description: "Up to 20 tags, max 50 chars each" }), + type: z.enum(["note", "diagram"]).default("note").optional().openapi({ + example: "note", + description: "Note type: 'note' or 'diagram' (defaults to 'note' if not specified)", + }), encryptedTitle: z .string() .optional() @@ -411,6 +418,10 @@ export const updateNoteRequestSchema = z .max(20) .optional() .openapi({ example: ["work"], description: "Up to 20 tags" }), + type: z + .enum(["note", "diagram"]) + .optional() + .openapi({ example: "note", description: "Note type: 'note' or 'diagram'" }), encryptedTitle: z .string() .optional() @@ -470,6 +481,14 @@ export const notesQueryParamsSchema = z example: "false", description: "Filter by hidden status", }), + type: z + .enum(["note", "diagram"]) + .optional() + .openapi({ + param: { name: "type", in: "query" }, + example: "diagram", + description: "Filter by note type", + }), search: z .string() .max(100) diff --git a/src/lib/validation.ts b/src/lib/validation.ts index fdc9378..b7386b1 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -45,6 +45,7 @@ export const createNoteSchema = z.object({ ), starred: z.boolean().optional(), tags: z.array(z.string().max(50)).max(20).optional(), + type: z.enum(["note", "diagram"]).default("note").optional(), encryptedTitle: z.string().optional(), encryptedContent: z.string().optional(), @@ -70,6 +71,7 @@ export const updateNoteSchema = z.object({ deleted: z.boolean().optional(), hidden: z.boolean().optional(), tags: z.array(z.string().max(50)).max(20).optional(), + type: z.enum(["note", "diagram"]).optional(), encryptedTitle: z.string().optional(), encryptedContent: z.string().optional(), @@ -89,6 +91,7 @@ export const notesQuerySchema = z archived: z.coerce.boolean().optional(), deleted: z.coerce.boolean().optional(), hidden: z.coerce.boolean().optional(), + type: z.enum(["note", "diagram"]).optional(), search: z .string() .max(100) diff --git a/src/routes/notes/crud.ts b/src/routes/notes/crud.ts index 9a01c6a..e26fa45 100644 --- a/src/routes/notes/crud.ts +++ b/src/routes/notes/crud.ts @@ -76,6 +76,10 @@ const listNotesHandler: RouteHandler = async (c) => { conditions.push(eq(notes.hidden, hidden)); } + if (query.type !== undefined) { + conditions.push(eq(notes.type, query.type)); + } + if (query.search) { const escapedSearch = query.search .replace(/\\/g, "\\\\") @@ -282,6 +286,7 @@ const createNoteHandler: RouteHandler = async (c) => { folderId?: string | null; starred?: boolean; tags?: string[]; + type?: "note" | "diagram"; encryptedTitle?: string; encryptedContent?: string; iv?: string; diff --git a/src/version.ts b/src/version.ts index da01141..68af4f4 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.11.11" \ No newline at end of file +export const VERSION = "1.11.11";