notes: add per-note checklist (to-do list) option
Task #420 — answers the user request "وين خيار اضيف list to do?". Schema (lib/db/src/schema/notes.ts): - notes + note_recipients gain `kind` (varchar(16) default 'text') and `items` (jsonb<ChecklistItem[]> nullable). Snapshot copy on note_recipients keeps delivered checklists immutable across sender edits/deletes. - Drizzle push applied; lib/db .d.ts rebuilt. API (artifacts/api-server/src/routes/notes.ts): - ChecklistItem type + bounded parser (≤200 items, id ≤64, text ≤500). - parseNoteInput normalizes items↔kind on create and on PATCH that carries `kind`; PATCH handler additionally coerces items-only patches against the note's existing kind so a text note can never end up with checklist items (and vice versa). - POST/PATCH/send/loadRecipientsForNote/received/thread responses and the realtime `note_received` payload all carry kind+items, with the thread response falling back to the recipient snapshot. Client (artifacts/tx-os/src/lib/notes-api.ts + pages/notes.tsx): - Note/ReceivedNote/NoteThread/SentNoteRecipient extended with kind+items. - New `ChecklistEditor`, `ChecklistView`, and `KindToggle` components. - Composer and EditNoteDialog gain the to-do toggle (ListTodo icon) and switch between Textarea and ChecklistEditor; saves send kind+items, with empty-checklist auto-discard mirroring the existing empty-text behaviour. - NoteCard, inbox list, sent list, and ThreadDialog body render the checklist (read-only on snapshots; owner cards can toggle done via PATCH with stopPropagation so the edit dialog doesn't open). i18n: notes.checklist.{toggle,addItem,itemPlaceholder,emptyHint, removeItem,progress} added to en.json + ar.json. Tests: new artifacts/tx-os/tests/notes-checklist.spec.mjs covers composer→persist→reload→toggle, items-only PATCH normalization on both kinds, and checklist delivery to recipient snapshot. All 3 pass. Architect review (evaluate_task) flagged one real issue: items-only PATCH normalization. Fixed in the PATCH handler and locked in by the new normalization test. Pre-existing executive-meetings.ts tsc errors are unchanged and unrelated to this task.
This commit is contained in:
@@ -57,16 +57,42 @@ function parseLabelIds(v: unknown): number[] | undefined {
|
||||
return out;
|
||||
}
|
||||
|
||||
type ChecklistItem = { id: string; text: string; done: boolean };
|
||||
|
||||
interface NoteInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: string;
|
||||
kind?: "text" | "checklist";
|
||||
items?: ChecklistItem[] | null;
|
||||
isPinned?: boolean;
|
||||
isArchived?: boolean;
|
||||
labelIds?: number[];
|
||||
folderId?: number | null;
|
||||
}
|
||||
|
||||
function parseKind(v: unknown): "text" | "checklist" | undefined {
|
||||
return v === "text" || v === "checklist" ? v : undefined;
|
||||
}
|
||||
|
||||
function parseItems(v: unknown): ChecklistItem[] | null | undefined {
|
||||
if (v === null) return null;
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
if (v.length > 200) return undefined;
|
||||
const out: ChecklistItem[] = [];
|
||||
for (const it of v) {
|
||||
if (!it || typeof it !== "object") return undefined;
|
||||
const obj = it as Record<string, unknown>;
|
||||
const id = typeof obj.id === "string" && obj.id.length > 0 && obj.id.length <= 64
|
||||
? obj.id : null;
|
||||
const text = typeof obj.text === "string" ? obj.text.slice(0, 500) : null;
|
||||
const done = typeof obj.done === "boolean" ? obj.done : false;
|
||||
if (id === null || text === null) return undefined;
|
||||
out.push({ id, text, done });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } {
|
||||
if (v === null) return { ok: true, value: null };
|
||||
const n = Number(v);
|
||||
@@ -107,6 +133,26 @@ function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInp
|
||||
if (c === undefined) return { ok: false, error: "Invalid color" };
|
||||
data.color = c;
|
||||
} else if (isCreate) data.color = "default";
|
||||
if (body.kind !== undefined) {
|
||||
const k = parseKind(body.kind);
|
||||
if (!k) return { ok: false, error: "Invalid kind" };
|
||||
data.kind = k;
|
||||
} else if (isCreate) data.kind = "text";
|
||||
if (body.items !== undefined) {
|
||||
const its = parseItems(body.items);
|
||||
if (its === undefined) return { ok: false, error: "Invalid items" };
|
||||
data.items = its;
|
||||
}
|
||||
// Normalize items to match kind whenever kind is being set explicitly so
|
||||
// the column is never out of sync (e.g. checklist-mode without items, or
|
||||
// text-mode that still carries stale items from the previous shape).
|
||||
if (data.kind !== undefined) {
|
||||
if (data.kind === "checklist") {
|
||||
data.items = data.items ?? [];
|
||||
} else {
|
||||
data.items = null;
|
||||
}
|
||||
}
|
||||
if (body.isPinned !== undefined) {
|
||||
if (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" };
|
||||
data.isPinned = body.isPinned;
|
||||
@@ -214,6 +260,8 @@ async function loadRecipientsForNote(noteId: number) {
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
color: r.color,
|
||||
kind: r.kind,
|
||||
items: r.items,
|
||||
recipient: userMap.get(r.recipientUserId) ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -283,6 +331,8 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
|
||||
title: data.title ?? "",
|
||||
content: data.content ?? "",
|
||||
color: data.color ?? "default",
|
||||
kind: data.kind ?? "text",
|
||||
items: data.items ?? null,
|
||||
isPinned: data.isPinned ?? false,
|
||||
isArchived: data.isArchived ?? false,
|
||||
folderId: folderId ?? null,
|
||||
@@ -318,6 +368,17 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
res.status(400).json({ error: "Invalid folderId" });
|
||||
return;
|
||||
}
|
||||
// If the caller patched `items` without specifying `kind`, normalize
|
||||
// against the note's current kind so a text note can never end up
|
||||
// carrying checklist items (and vice versa). parseNoteInput handles the
|
||||
// case where `kind` is provided; this covers the items-only PATCH.
|
||||
if (data.kind === undefined && data.items !== undefined) {
|
||||
if (existing.kind === "checklist") {
|
||||
data.items = data.items ?? [];
|
||||
} else {
|
||||
data.items = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length > 0) {
|
||||
await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id));
|
||||
}
|
||||
@@ -411,6 +472,8 @@ router.get("/notes/received", requireAuth, async (req, res): Promise<void> => {
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
color: r.color,
|
||||
kind: r.kind,
|
||||
items: r.items,
|
||||
createdAt: r.sentAt,
|
||||
updatedAt: r.readAt ?? r.sentAt,
|
||||
sender: senderMap.get(r.senderUserId) ?? null,
|
||||
@@ -510,11 +573,16 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp
|
||||
const contentFallback = snapshotRow?.content ?? note?.content ?? "";
|
||||
const colorFallback = snapshotRow?.color ?? note?.color ?? "default";
|
||||
|
||||
const kindFallback = snapshotRow?.kind ?? note?.kind ?? "text";
|
||||
const itemsFallback = snapshotRow?.items ?? note?.items ?? null;
|
||||
|
||||
res.json({
|
||||
id: id.id,
|
||||
title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback,
|
||||
content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback,
|
||||
color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback,
|
||||
kind: useSnapshot ? recipientRow!.kind : note?.kind ?? kindFallback,
|
||||
items: useSnapshot ? recipientRow!.items : note?.items ?? itemsFallback,
|
||||
createdAt: useSnapshot
|
||||
? recipientRow!.sentAt
|
||||
: note?.createdAt ?? snapshotRow?.sentAt ?? null,
|
||||
@@ -595,6 +663,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
color: note.color,
|
||||
kind: note.kind,
|
||||
items: note.items,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
@@ -625,6 +695,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
color: r.color,
|
||||
kind: r.kind,
|
||||
items: r.items,
|
||||
sentAt: r.sentAt,
|
||||
senderUserId: userId,
|
||||
sender: sender ?? null,
|
||||
|
||||
Reference in New Issue
Block a user