mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 06:36:21 +00:00
fix: clinic creation gating, analysis line charts, username + task assignee, delete confirm
- Hide "Create clinic" (sidebar footer) for non-admins; only owner/admin can spin up additional clinics. Onboarding for brand-new users is unaffected. - Analysis: drop the bar charts; show line charts (Sparkline) inside KPI cards that open a detail dialog with the full chart + per-point breakdown. - Add Team Member: validate the username client-side (no spaces; letters, numbers, dots, underscores) with a clear warning + field hint. - Tasks: New Task now has an Assignee selector (Myself / Other → department). Tasks are visible to the department they're assigned to (or the creator), and show who created them. Backend adds assignee_role + created_by_name with visibility filtering in listTasks; owners/admins see all. - Care team: removing a member now asks for confirmation first (dialog) and surfaces success/failure + refreshes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,9 @@ export const tasks = pgTable(
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
assignee: text("assignee").notNull().default("Unassigned"),
|
||||
// The department (member role) a task is assigned to, e.g. "reception".
|
||||
// Null means a personal task belonging to its creator. Drives who sees it.
|
||||
assigneeRole: text("assignee_role"),
|
||||
due: text("due").notNull().default("No due date"),
|
||||
priority: text("priority").$type<TaskPriority>().notNull(),
|
||||
patient: text("patient"),
|
||||
@@ -30,6 +33,9 @@ export const tasks = pgTable(
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Denormalised creator name so "created by …" survives even if the user is
|
||||
// later removed (createdBy is set null on delete).
|
||||
createdByName: text("created_by_name"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Departments a task can be assigned to (member roles). Null = a personal task
|
||||
// that belongs to its creator.
|
||||
export const TASK_DEPARTMENTS = ["admin", "doctor", "reception"] as const;
|
||||
|
||||
// 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"),
|
||||
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
|
||||
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(),
|
||||
|
||||
@@ -19,7 +19,12 @@ tasksRouter.get(
|
||||
requirePermission({ task: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json(await service.listTasks(req.organizationId!));
|
||||
res.json(
|
||||
await service.listTasks(req.organizationId!, {
|
||||
userId: req.user!.id,
|
||||
role: req.memberRole ?? "",
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -34,7 +39,7 @@ tasksRouter.post(
|
||||
const input = taskInputSchema.parse(req.body);
|
||||
const created = await service.createTask(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
{ id: req.user!.id, name: req.user!.name },
|
||||
input,
|
||||
);
|
||||
await recordActivity({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { tasks } from "../db/schema/tasks.js";
|
||||
@@ -11,33 +11,58 @@ type TaskRow = typeof tasks.$inferSelect;
|
||||
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,
|
||||
due: row.due,
|
||||
priority: row.priority,
|
||||
patient: row.patient,
|
||||
notes: row.notes,
|
||||
done: row.done,
|
||||
createdById: row.createdBy,
|
||||
createdByName: row.createdByName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTasks(orgId: string): Promise<Task[]> {
|
||||
// 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)];
|
||||
if (roles.length) visible.push(inArray(tasks.assigneeRole, roles));
|
||||
where = and(where, or(...visible))!;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.organizationId, orgId))
|
||||
.where(where)
|
||||
.orderBy(desc(tasks.createdAt));
|
||||
return rows.map(toTask);
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
creator: { id: string; name: string },
|
||||
input: TaskInput,
|
||||
): Promise<Task> {
|
||||
const [row] = await db
|
||||
@@ -46,11 +71,13 @@ export async function createTask(
|
||||
organizationId: orgId,
|
||||
title: input.title,
|
||||
assignee: input.assignee,
|
||||
assigneeRole: input.assigneeRole ?? null,
|
||||
due: input.due,
|
||||
priority: input.priority,
|
||||
patient: input.patient ?? null,
|
||||
notes: input.notes ?? null,
|
||||
createdBy: userId,
|
||||
createdBy: creator.id,
|
||||
createdByName: creator.name,
|
||||
})
|
||||
.returning();
|
||||
return toTask(row!);
|
||||
@@ -67,6 +94,8 @@ export async function updateTask(
|
||||
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.due !== undefined) set.due = patch.due;
|
||||
if (patch.priority !== undefined) set.priority = patch.priority;
|
||||
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
|
||||
|
||||
@@ -7,11 +7,15 @@ export type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
assignee: string;
|
||||
// Department (member role) the task is assigned to; null = personal task.
|
||||
assigneeRole: string | null;
|
||||
due: string;
|
||||
priority: TaskPriority;
|
||||
patient: string | null;
|
||||
notes: string | null;
|
||||
done: boolean;
|
||||
createdById: string | null;
|
||||
createdByName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user