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:
riyadhafraa
2026-05-06 10:12:52 +00:00
parent d4e0dfc6e5
commit 1ffb470e8f
8 changed files with 679 additions and 36 deletions
+72
View File
@@ -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,