mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Add notes API: clinic + author-scoped CRUD
New `note` table (src/db/schema/notes.ts) referencing organization + user, with org+author scoping so a doctor only sees their own notes within the active clinic. Adds: - src/types/note.ts + src/lib/note-validation.ts (zod) - src/services/notes.ts (CRUD, treats non-uuid ids as not-found) - src/routes/notes.ts mounted at /api/notes, gated requireAuth → requireOrg - schema barrel + generated migration drizzle/0001_*.sql (applied on startup by the runtime migrator) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "notes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"author_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"content" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "notes_org_author_idx" ON "notes" USING btree ("organization_id","author_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1780413912874,
|
||||
"tag": "0000_small_sir_ram",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1780591375930,
|
||||
"tag": "0001_boring_puck",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./auth.js";
|
||||
export * from "./patients.js";
|
||||
export * from "./notes.js";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// A doctor's freeform note, scoped to a clinic (organization) AND its author —
|
||||
// notes are private to the doctor who wrote them within that clinic. `content`
|
||||
// holds the rich-text editor's HTML.
|
||||
export const notes = pgTable(
|
||||
"notes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
authorId: text("author_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [index("notes_org_author_idx").on(t.organizationId, t.authorId)],
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import express from "express";
|
||||
import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { errorHandler, notFound } from "./middleware/error.js";
|
||||
import { notesRouter } from "./routes/notes.js";
|
||||
import { patientsRouter } from "./routes/patients.js";
|
||||
|
||||
const app = express();
|
||||
@@ -43,6 +44,7 @@ app.get("/health", (_req, res) => {
|
||||
});
|
||||
|
||||
app.use("/api/patients", patientsRouter);
|
||||
app.use("/api/notes", notesRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
@@ -51,4 +53,5 @@ app.listen(env.PORT, () => {
|
||||
console.log(`temetro backend listening on ${env.BETTER_AUTH_URL}`);
|
||||
console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`);
|
||||
console.log(` • patients: /api/patients`);
|
||||
console.log(` • notes: /api/notes`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Payload accepted by POST/PUT /api/notes. `content` is editor HTML (capped to
|
||||
// avoid unbounded payloads).
|
||||
export const noteInputSchema = z.object({
|
||||
title: z.string().trim().min(1, "Title is required.").max(200),
|
||||
content: z.string().max(100_000).default(""),
|
||||
});
|
||||
|
||||
export type NoteInput = z.infer<typeof noteInputSchema>;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { noteInputSchema } from "../lib/note-validation.js";
|
||||
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
||||
import * as service from "../services/notes.js";
|
||||
|
||||
export const notesRouter = Router();
|
||||
|
||||
// Notes are scoped to the active clinic AND the signed-in author. Any clinic
|
||||
// member may manage their own notes, so no extra RBAC permission is required.
|
||||
notesRouter.use(requireAuth, requireOrg);
|
||||
|
||||
notesRouter.get("/", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await service.listNotes(req.organizationId!, req.user!.id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
notesRouter.post("/", async (req, res, next) => {
|
||||
try {
|
||||
const input = noteInputSchema.parse(req.body);
|
||||
const created = await service.createNote(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input,
|
||||
);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
notesRouter.get("/:id", async (req, res, next) => {
|
||||
try {
|
||||
const note = await service.getNote(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
);
|
||||
if (!note) throw new HttpError(404, "Note not found.");
|
||||
res.json(note);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
notesRouter.put("/:id", async (req, res, next) => {
|
||||
try {
|
||||
const input = noteInputSchema.parse(req.body);
|
||||
const updated = await service.updateNote(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
input,
|
||||
);
|
||||
if (!updated) throw new HttpError(404, "Note not found.");
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
notesRouter.delete("/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await service.deleteNote(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
);
|
||||
if (!ok) throw new HttpError(404, "Note not found.");
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { notes } from "../db/schema/notes.js";
|
||||
import type { NoteInput } from "../lib/note-validation.js";
|
||||
import type { Note } from "../types/note.js";
|
||||
|
||||
type NoteRow = typeof notes.$inferSelect;
|
||||
|
||||
// Postgres throws on a malformed uuid; treat non-uuid ids as "not found".
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function toNote(row: NoteRow): Note {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listNotes(
|
||||
orgId: string,
|
||||
authorId: string,
|
||||
): Promise<Note[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.organizationId, orgId), eq(notes.authorId, authorId)))
|
||||
.orderBy(desc(notes.updatedAt));
|
||||
return rows.map(toNote);
|
||||
}
|
||||
|
||||
export async function getNote(
|
||||
orgId: string,
|
||||
authorId: string,
|
||||
id: string,
|
||||
): Promise<Note | null> {
|
||||
if (!UUID_RE.test(id)) return null;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.id, id),
|
||||
eq(notes.organizationId, orgId),
|
||||
eq(notes.authorId, authorId),
|
||||
),
|
||||
);
|
||||
return row ? toNote(row) : null;
|
||||
}
|
||||
|
||||
export async function createNote(
|
||||
orgId: string,
|
||||
authorId: string,
|
||||
input: NoteInput,
|
||||
): Promise<Note> {
|
||||
const [row] = await db
|
||||
.insert(notes)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
authorId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
})
|
||||
.returning();
|
||||
return toNote(row!);
|
||||
}
|
||||
|
||||
export async function updateNote(
|
||||
orgId: string,
|
||||
authorId: string,
|
||||
id: string,
|
||||
input: NoteInput,
|
||||
): Promise<Note | null> {
|
||||
if (!UUID_RE.test(id)) return null;
|
||||
const [row] = await db
|
||||
.update(notes)
|
||||
.set({ title: input.title, content: input.content })
|
||||
.where(
|
||||
and(
|
||||
eq(notes.id, id),
|
||||
eq(notes.organizationId, orgId),
|
||||
eq(notes.authorId, authorId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return row ? toNote(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteNote(
|
||||
orgId: string,
|
||||
authorId: string,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
if (!UUID_RE.test(id)) return false;
|
||||
const deleted = await db
|
||||
.delete(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.id, id),
|
||||
eq(notes.organizationId, orgId),
|
||||
eq(notes.authorId, authorId),
|
||||
),
|
||||
)
|
||||
.returning({ id: notes.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// The canonical Note shape returned by the API. Mirrors the frontend
|
||||
// `lib/notes.ts` Note type. `content` is rich-text editor HTML.
|
||||
export type Note = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
Reference in New Issue
Block a user