feat: org-scoped tasks backend, wire tasks page

Add the tasks table, validation, service and routes (/api/tasks with a
PATCH for partial updates / the done toggle, RBAC-gated) and the frontend
data module. The tasks board now loads, creates and toggles real data
(optimistic toggle with rollback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-07 19:41:10 +03:00
parent 7ef9da59bd
commit 25254dd4c1
12 changed files with 2270 additions and 66 deletions
@@ -0,0 +1,18 @@
CREATE TABLE "tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"title" text NOT NULL,
"assignee" text DEFAULT 'Unassigned' NOT NULL,
"due" text DEFAULT 'No due date' NOT NULL,
"priority" text NOT NULL,
"patient" text,
"notes" text,
"done" boolean DEFAULT false NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "tasks_org_idx" ON "tasks" USING btree ("organization_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1780850147682,
"tag": "0003_lame_rawhide_kid",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780850364853,
"tag": "0004_dizzy_scarlet_spider",
"breakpoints": true
}
]
}
+1
View File
@@ -3,3 +3,4 @@ export * from "./patients.js";
export * from "./notes.js";
export * from "./appointments.js";
export * from "./prescriptions.js";
export * from "./tasks.js";
+40
View File
@@ -0,0 +1,40 @@
import {
boolean,
index,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import type { TaskPriority } from "../../types/task.js";
import { organization, user } from "./auth.js";
// One row per care-team to-do, scoped to a clinic (organization). Shared across
// the clinic (unlike notes, which are per-author). `assignee`/`due`/`patient`
// are free text to match the lightweight board UI.
export const tasks = pgTable(
"tasks",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
title: text("title").notNull(),
assignee: text("assignee").notNull().default("Unassigned"),
due: text("due").notNull().default("No due date"),
priority: text("priority").$type<TaskPriority>().notNull(),
patient: text("patient"),
notes: text("notes"),
done: boolean("done").notNull().default(false),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [index("tasks_org_idx").on(t.organizationId)],
);
+3
View File
@@ -9,6 +9,7 @@ import { appointmentsRouter } from "./routes/appointments.js";
import { notesRouter } from "./routes/notes.js";
import { patientsRouter } from "./routes/patients.js";
import { prescriptionsRouter } from "./routes/prescriptions.js";
import { tasksRouter } from "./routes/tasks.js";
const app = express();
@@ -49,6 +50,7 @@ app.use("/api/patients", patientsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
app.use("/api/tasks", tasksRouter);
app.use(notFound);
app.use(errorHandler);
@@ -60,4 +62,5 @@ app.listen(env.PORT, () => {
console.log(` • notes: /api/notes`);
console.log(` • appts: /api/appointments`);
console.log(` • rx: /api/prescriptions`);
console.log(` • tasks: /api/tasks`);
});
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
// Payload accepted by POST /api/tasks (full create).
export const taskInputSchema = z.object({
title: z.string().trim().min(1, "A task subject is required.").max(200),
assignee: z.string().trim().max(200).default("Unassigned"),
due: z.string().trim().max(120).default("No due date"),
priority: z.enum(["high", "medium", "low"]).default("medium"),
patient: z.string().trim().max(200).nullish(),
notes: z.string().max(5000).nullish(),
});
// Payload accepted by PATCH /api/tasks/:id — any subset of fields, plus `done`
// (used by the list/detail toggle).
export const taskPatchSchema = taskInputSchema.partial().extend({
done: z.boolean().optional(),
});
export type TaskInput = z.infer<typeof taskInputSchema>;
export type TaskPatch = z.infer<typeof taskPatchSchema>;
+80
View File
@@ -0,0 +1,80 @@
import { Router } from "express";
import { HttpError } from "../lib/http-error.js";
import { taskInputSchema, taskPatchSchema } from "../lib/task-validation.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import * as service from "../services/tasks.js";
export const tasksRouter = Router();
tasksRouter.use(requireAuth, requireOrg);
tasksRouter.get(
"/",
requirePermission({ task: ["read"] }),
async (req, res, next) => {
try {
res.json(await service.listTasks(req.organizationId!));
} catch (err) {
next(err);
}
},
);
tasksRouter.post(
"/",
requirePermission({ task: ["write"] }),
async (req, res, next) => {
try {
const input = taskInputSchema.parse(req.body);
const created = await service.createTask(
req.organizationId!,
req.user!.id,
input,
);
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
tasksRouter.patch(
"/:id",
requirePermission({ task: ["write"] }),
async (req, res, next) => {
try {
const patch = taskPatchSchema.parse(req.body);
const updated = await service.updateTask(
req.organizationId!,
req.params.id as string,
patch,
);
if (!updated) throw new HttpError(404, "Task not found.");
res.json(updated);
} catch (err) {
next(err);
}
},
);
tasksRouter.delete(
"/:id",
requirePermission({ task: ["delete"] }),
async (req, res, next) => {
try {
const ok = await service.deleteTask(
req.organizationId!,
req.params.id as string,
);
if (!ok) throw new HttpError(404, "Task not found.");
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+99
View File
@@ -0,0 +1,99 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { tasks } from "../db/schema/tasks.js";
import type { TaskInput, TaskPatch } from "../lib/task-validation.js";
import type { Task } from "../types/task.js";
type TaskRow = typeof tasks.$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 toTask(row: TaskRow): Task {
return {
id: row.id,
title: row.title,
assignee: row.assignee,
due: row.due,
priority: row.priority,
patient: row.patient,
notes: row.notes,
done: row.done,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function listTasks(orgId: string): Promise<Task[]> {
const rows = await db
.select()
.from(tasks)
.where(eq(tasks.organizationId, orgId))
.orderBy(desc(tasks.createdAt));
return rows.map(toTask);
}
export async function createTask(
orgId: string,
userId: string,
input: TaskInput,
): Promise<Task> {
const [row] = await db
.insert(tasks)
.values({
organizationId: orgId,
title: input.title,
assignee: input.assignee,
due: input.due,
priority: input.priority,
patient: input.patient ?? null,
notes: input.notes ?? null,
createdBy: userId,
})
.returning();
return toTask(row!);
}
export async function updateTask(
orgId: string,
id: string,
patch: TaskPatch,
): Promise<Task | null> {
if (!UUID_RE.test(id)) return null;
// Build a set object from only the provided fields.
const set: Partial<typeof tasks.$inferInsert> = {};
if (patch.title !== undefined) set.title = patch.title;
if (patch.assignee !== undefined) set.assignee = patch.assignee;
if (patch.due !== undefined) set.due = patch.due;
if (patch.priority !== undefined) set.priority = patch.priority;
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
if (patch.notes !== undefined) set.notes = patch.notes ?? null;
if (patch.done !== undefined) set.done = patch.done;
if (Object.keys(set).length === 0) {
const [row] = await db
.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.organizationId, orgId)));
return row ? toTask(row) : null;
}
const [row] = await db
.update(tasks)
.set(set)
.where(and(eq(tasks.id, id), eq(tasks.organizationId, orgId)))
.returning();
return row ? toTask(row) : null;
}
export async function deleteTask(orgId: string, id: string): Promise<boolean> {
if (!UUID_RE.test(id)) return false;
const deleted = await db
.delete(tasks)
.where(and(eq(tasks.id, id), eq(tasks.organizationId, orgId)))
.returning({ id: tasks.id });
return deleted.length > 0;
}
+17
View File
@@ -0,0 +1,17 @@
// The canonical Task shape returned by the API. Mirrors the frontend
// `lib/tasks.ts` Task type. Scoped to the active clinic (a shared care-team
// to-do board). `patient` is an optional free-text reference for context.
export type TaskPriority = "high" | "medium" | "low";
export type Task = {
id: string;
title: string;
assignee: string;
due: string;
priority: TaskPriority;
patient: string | null;
notes: string | null;
done: boolean;
createdAt: string;
updatedAt: string;
};
+56 -66
View File
@@ -1,7 +1,13 @@
"use client";
import { Check, Plus } from "lucide-react";
import { type FormEvent, type ReactNode, useMemo, useState } from "react";
import {
type FormEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { TaskDetailSheet } from "@/components/tasks/task-detail-sheet";
import { Badge } from "@/components/ui/badge";
@@ -18,24 +24,18 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
type Priority,
type Task,
type TaskInput,
createTask,
listTasks,
updateTask,
} from "@/lib/tasks";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
// All tasks here are mock/placeholder data — there is no tasks backend. They
// illustrate a care-team to-do board.
export type Priority = "high" | "medium" | "low";
export type Task = {
id: string;
title: string;
assignee: string;
due: string;
priority: Priority;
patient?: string;
notes?: string;
done: boolean;
};
export type { Priority, Task } from "@/lib/tasks";
type Filter = "all" | "open" | "done";
@@ -52,46 +52,6 @@ const priorityLabel: Record<Priority, string> = {
low: "Low",
};
const seed: Task[] = [
{
id: "1",
title: "Review Amina Yusuf's lab results",
assignee: "Dr. Okafor",
due: "Today",
priority: "high",
patient: "Amina Yusuf · #10293",
notes: "Lipid panel + HbA1c back. Decide whether to adjust the plan before her follow-up.",
done: false,
},
{
id: "2",
title: "Confirm Daniel Mensah's prior records import",
assignee: "Reception",
due: "Today",
priority: "medium",
patient: "Daniel Mensah · #10311",
notes: "Check the import completed before tomorrow's 10:00 appointment.",
done: false,
},
{
id: "3",
title: "Call Carlos Rivera about expired statin",
assignee: "Dr. Okafor",
due: "Tomorrow",
priority: "medium",
patient: "Carlos Rivera · #10358",
done: false,
},
{
id: "4",
title: "Restock vaccination fridge log",
assignee: "Care team",
due: "Jun 8",
priority: "low",
done: true,
},
];
function CheckButton({
done,
onClick,
@@ -136,7 +96,7 @@ function AddTaskDialog({
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (task: Omit<Task, "id" | "done">) => void;
onAdd: (task: TaskInput) => void;
}) {
const [title, setTitle] = useState("");
const [notes, setNotes] = useState("");
@@ -246,12 +206,26 @@ function AddTaskDialog({
}
export function TasksView() {
const [tasks, setTasks] = useState<Task[]>(seed);
const [tasks, setTasks] = useState<Task[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [filter, setFilter] = useState<Filter>("all");
const [addOpen, setAddOpen] = useState(false);
useEffect(() => {
let active = true;
listTasks()
.then((data) => {
if (active) setTasks(data);
})
.catch(() => {
/* api-client redirects on 401; otherwise leave the list empty */
});
return () => {
active = false;
};
}, []);
const selected = tasks.find((t) => t.id === selectedId) ?? null;
const visible = useMemo(() => {
@@ -260,21 +234,37 @@ export function TasksView() {
return tasks;
}, [tasks, filter]);
const toggle = (id: string) =>
// Optimistically flip done, then persist; roll back on failure.
const toggle = async (id: string) => {
const current = tasks.find((t) => t.id === id);
if (!current) return;
const next = !current.done;
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
prev.map((t) => (t.id === id ? { ...t, done: next } : t)),
);
try {
await updateTask(id, { done: next });
} catch {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: current.done } : t)),
);
notify.error("Couldn't update task", "Please try again.");
}
};
const openTask = (id: string) => {
setSelectedId(id);
setSheetOpen(true);
};
const addTask = (task: Omit<Task, "id" | "done">) =>
setTasks((prev) => [
{ ...task, id: `t-${Date.now()}`, done: false },
...prev,
]);
const addTask = async (task: TaskInput) => {
try {
const created = await createTask(task);
setTasks((prev) => [created, ...prev]);
} catch {
notify.error("Couldn't add task", "Please try again.");
}
};
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
@@ -282,7 +272,7 @@ export function TasksView() {
<div>
<h1 className="font-semibold text-2xl tracking-tight">Tasks</h1>
<p className="text-muted-foreground text-sm">
Care-team to-dos. Click a task to see its details. Sample data.
Care-team to-dos. Click a task to see its details.
</p>
</div>
<Button
+53
View File
@@ -0,0 +1,53 @@
import { apiFetch } from "@/lib/api-client";
// A care-team task. Mirrors the backend `src/types/task.ts`. Scoped to the active
// clinic (shared across the care team).
export type Priority = "high" | "medium" | "low";
export type Task = {
id: string;
title: string;
assignee: string;
due: string;
priority: Priority;
patient: string | null;
notes: string | null;
done: boolean;
createdAt: string;
updatedAt: string;
};
// Fields the "New task" dialog collects.
export type TaskInput = {
title: string;
assignee?: string;
due?: string;
priority?: Priority;
patient?: string | null;
notes?: string | null;
};
// Any subset of fields, plus `done` (used by the complete/reopen toggle).
export type TaskPatch = Partial<TaskInput> & { done?: boolean };
export function listTasks(): Promise<Task[]> {
return apiFetch<Task[]>("/api/tasks");
}
export function createTask(input: TaskInput): Promise<Task> {
return apiFetch<Task>("/api/tasks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateTask(id: string, patch: TaskPatch): Promise<Task> {
return apiFetch<Task>(`/api/tasks/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
export function deleteTask(id: string): Promise<void> {
return apiFetch<void>(`/api/tasks/${id}`, { method: "DELETE" });
}