import { pgTable, text, serial, timestamp, integer, varchar, boolean, jsonb, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; import { usersTable } from "./users"; export const noteFoldersTable = pgTable("note_folders", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), name: varchar("name", { length: 80 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), }, (t) => ({ userNameUnique: uniqueIndex("note_folders_user_name_unique").on(t.userId, t.name), })); export type ChecklistItem = { id: string; text: string; done: boolean }; export const notesTable = pgTable("notes", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), folderId: integer("folder_id").references(() => noteFoldersTable.id, { onDelete: "set null" }), title: varchar("title", { length: 300 }).notNull().default(""), content: text("content").notNull().default(""), color: varchar("color", { length: 32 }).notNull().default("default"), // "text" (plain note) or "checklist" (per-note to-do list). Items live in // `items` for checklist notes; for text notes `items` is null. kind: varchar("kind", { length: 16 }).notNull().default("text"), items: jsonb("items").$type(), isPinned: boolean("is_pinned").notNull().default(false), isArchived: boolean("is_archived").notNull().default(false), // Per-bucket manual sort order. A "bucket" is the unique combination of // (userId, folderId, isPinned). Smaller numbers sort first; ties fall back // to updatedAt DESC so legacy notes (all default 0) keep their existing // recency-based order until the user manually reorders them. Updated by // POST /notes/reorder (drag-to-reorder) and on folder-change via PATCH // /notes/:id (the moved note is placed at the top of the destination // bucket so it lands where the user can see it immediately). sortOrder: integer("sort_order").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), }); export const noteLabelsTable = pgTable("note_labels", { id: serial("id").primaryKey(), userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), name: varchar("name", { length: 80 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ userNameUnique: uniqueIndex("note_labels_user_name_unique").on(t.userId, t.name), })); export const noteLabelAssignmentsTable = pgTable("note_label_assignments", { noteId: integer("note_id").notNull().references(() => notesTable.id, { onDelete: "cascade" }), labelId: integer("label_id").notNull().references(() => noteLabelsTable.id, { onDelete: "cascade" }), }, (t) => ({ pk: primaryKey({ columns: [t.noteId, t.labelId] }), })); /** * Per-recipient delivery row for a note that was sent from one user * (`senderUserId` / the owning `notes.user_id`) to another * (`recipientUserId`). One row is created per recipient at send time. * * Status lifecycle: `unread` -> `read` (recipient opens) -> `replied` * (recipient sends a reply). `archived` is recipient-side only and does * not affect the sender's view. */ export const noteRecipientsTable = pgTable("note_recipients", { id: serial("id").primaryKey(), // The original sender-side note id at send time. Stored as a plain integer // (no FK) so the recipient row — and the thread it anchors — survives the // sender deleting their own note. Together with the snapshot fields below // this guarantees recipient copies are fully independent of sender edits. noteId: integer("note_id").notNull(), senderUserId: integer("sender_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), recipientUserId: integer("recipient_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), // Immutable snapshot of the note as it was at send time. Sender edits to the // original note do NOT propagate to delivered copies. title: varchar("title", { length: 300 }).notNull().default(""), content: text("content").notNull().default(""), color: varchar("color", { length: 32 }).notNull().default("default"), // Snapshot of the source note's kind/items at send time so checklist // notes survive (and stay independent of) sender edits/deletes. kind: varchar("kind", { length: 16 }).notNull().default("text"), items: jsonb("items").$type(), status: varchar("status", { length: 16 }).notNull().default("unread"), sentAt: timestamp("sent_at", { withTimezone: true }).notNull().defaultNow(), readAt: timestamp("read_at", { withTimezone: true }), archivedAt: timestamp("archived_at", { withTimezone: true }), }, (t) => ({ noteRecipientUnique: uniqueIndex("note_recipients_note_recipient_unique").on(t.noteId, t.recipientUserId), })); export const noteRepliesTable = pgTable("note_replies", { id: serial("id").primaryKey(), noteId: integer("note_id").notNull(), senderUserId: integer("sender_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), recipientUserId: integer("recipient_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), content: text("content").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const insertNoteSchema = createInsertSchema(notesTable).omit({ id: true, createdAt: true, updatedAt: true }); export type InsertNote = z.infer; export type Note = typeof notesTable.$inferSelect; export const insertNoteLabelSchema = createInsertSchema(noteLabelsTable).omit({ id: true, createdAt: true }); export type InsertNoteLabel = z.infer; export type NoteLabel = typeof noteLabelsTable.$inferSelect; export const insertNoteFolderSchema = createInsertSchema(noteFoldersTable).omit({ id: true, createdAt: true, updatedAt: true }); export type InsertNoteFolder = z.infer; export type NoteFolder = typeof noteFoldersTable.$inferSelect; export type NoteRecipient = typeof noteRecipientsTable.$inferSelect; export type NoteReply = typeof noteRepliesTable.$inferSelect; /** * Folder-level live share. The folder owner (`noteFoldersTable.userId`) grants * a recipient (`recipientUserId`) read-only access to ALL notes currently in * the folder. Unlike `noteRecipientsTable`, this is NOT a snapshot — recipients * always see the owner's current notes via a join, so adds/edits/removes by the * owner are reflected live in the recipient's "Shared with me" view. * * Permission per recipient: "view" (default, read-only) or * "edit" (full create/update/delete on the owner's notes inside this folder). * Notes mutated by an editor still belong to the folder owner — the folder * stays a single namespace under `noteFoldersTable.userId`. */ export const noteFolderSharesTable = pgTable("note_folder_shares", { id: serial("id").primaryKey(), folderId: integer("folder_id") .notNull() .references(() => noteFoldersTable.id, { onDelete: "cascade" }), recipientUserId: integer("recipient_user_id") .notNull() .references(() => usersTable.id, { onDelete: "cascade" }), permission: varchar("permission", { length: 8 }).notNull().default("view"), sharedAt: timestamp("shared_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ folderRecipientUnique: uniqueIndex("note_folder_shares_folder_recipient_unique").on( t.folderId, t.recipientUserId, ), })); export type NoteFolderShare = typeof noteFolderSharesTable.$inferSelect;