mirror of
https://github.com/temetro/temetro.git
synced 2026-08-06 17:07:40 +00:00
bbc6869745
Backend: add tasks.assignee_user_id (nullable, fk user) + migration; persist it through create/update and surface tasks to the assigned person in listTasks. Frontend: New Task dialog gains a three-way Assignee control (Myself / Department / Person) with a provider picker from /api/staff/providers; task card + detail show the assignee's name when set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
4.5 KiB
TypeScript
149 lines
4.5 KiB
TypeScript
import { and, desc, eq, inArray, or } 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 rolesOf(role: string): string[] {
|
|
return String(role ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function toTask(row: TaskRow): Task {
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
assignee: row.assignee,
|
|
assigneeRole: row.assigneeRole,
|
|
assigneeUserId: row.assigneeUserId,
|
|
due: row.due,
|
|
priority: row.priority,
|
|
status: row.status,
|
|
patient: row.patient,
|
|
notes: row.notes,
|
|
done: row.done,
|
|
createdById: row.createdBy,
|
|
createdByName: row.createdByName,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
// A member sees a task if they created it or it's assigned to their department.
|
|
// Owners/admins see every task in the clinic.
|
|
export async function listTasks(
|
|
orgId: string,
|
|
viewer: { userId: string; role: string },
|
|
): Promise<Task[]> {
|
|
const roles = rolesOf(viewer.role);
|
|
const isAdmin = roles.some((r) => r === "owner" || r === "admin");
|
|
|
|
let where = eq(tasks.organizationId, orgId);
|
|
if (!isAdmin) {
|
|
const visible = [
|
|
eq(tasks.createdBy, viewer.userId),
|
|
// Tasks assigned to this person specifically.
|
|
eq(tasks.assigneeUserId, viewer.userId),
|
|
];
|
|
if (roles.length) visible.push(inArray(tasks.assigneeRole, roles));
|
|
where = and(where, or(...visible))!;
|
|
}
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(tasks)
|
|
.where(where)
|
|
.orderBy(desc(tasks.createdAt));
|
|
return rows.map(toTask);
|
|
}
|
|
|
|
export async function createTask(
|
|
orgId: string,
|
|
creator: { id: string; name: string },
|
|
input: TaskInput,
|
|
): Promise<Task> {
|
|
const [row] = await db
|
|
.insert(tasks)
|
|
.values({
|
|
organizationId: orgId,
|
|
title: input.title,
|
|
assignee: input.assignee,
|
|
assigneeRole: input.assigneeRole ?? null,
|
|
assigneeUserId: input.assigneeUserId ?? null,
|
|
due: input.due,
|
|
priority: input.priority,
|
|
status: input.status,
|
|
// Keep the legacy `done` flag in lock-step with the board column.
|
|
done: input.status === "done",
|
|
patient: input.patient ?? null,
|
|
notes: input.notes ?? null,
|
|
createdBy: creator.id,
|
|
createdByName: creator.name,
|
|
})
|
|
.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.assigneeRole !== undefined)
|
|
set.assigneeRole = patch.assigneeRole ?? null;
|
|
if (patch.assigneeUserId !== undefined)
|
|
set.assigneeUserId = patch.assigneeUserId ?? null;
|
|
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;
|
|
// Keep `status` and the legacy `done` flag in sync: a status patch wins and
|
|
// sets done; a bare done toggle maps to done/todo.
|
|
if (patch.status !== undefined) {
|
|
set.status = patch.status;
|
|
set.done = patch.status === "done";
|
|
} else if (patch.done !== undefined) {
|
|
set.done = patch.done;
|
|
set.status = patch.done ? "done" : "todo";
|
|
}
|
|
|
|
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;
|
|
}
|