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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+19
View File
@@ -1,5 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export type NoteKind = "text" | "checklist";
export interface ChecklistItem {
id: string;
text: string;
done: boolean;
}
export interface Note {
id: number;
userId: number;
@@ -7,6 +15,8 @@ export interface Note {
title: string;
content: string;
color: string;
kind: NoteKind;
items: ChecklistItem[] | null;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
@@ -49,6 +59,11 @@ export interface SentNoteRecipient {
sentAt: string;
readAt: string | null;
archivedAt: string | null;
title?: string;
content?: string;
color?: string;
kind?: NoteKind;
items?: ChecklistItem[] | null;
recipient: UserSummary | null;
}
@@ -62,6 +77,8 @@ export interface ReceivedNote {
title: string;
content: string;
color: string;
kind: NoteKind;
items: ChecklistItem[] | null;
createdAt: string;
updatedAt: string;
sender: UserSummary | null;
@@ -87,6 +104,8 @@ export interface NoteThread {
title: string;
content: string;
color: string;
kind: NoteKind;
items: ChecklistItem[] | null;
createdAt: string;
updatedAt: string;
senderUserId: number;
+9 -1
View File
@@ -1138,7 +1138,15 @@
"recipientsCount_other": "{{count}} مستلمين",
"repliesCount_one": "رد واحد",
"repliesCount_other": "{{count}} ردود",
"openThread": "فتح"
"openThread": "فتح",
"checklist": {
"toggle": "قائمة مهام",
"addItem": "إضافة عنصر",
"itemPlaceholder": "عنصر القائمة",
"emptyHint": "لا توجد عناصر بعد — أضف عنصراً أدناه.",
"removeItem": "حذف العنصر",
"progress": "{{done}} / {{total}}"
}
},
"executiveMeetings": {
"title": "قائمة الاجتماعات",
+9 -1
View File
@@ -1039,7 +1039,15 @@
},
"conversationWith": "Conversation with {{name}}",
"ownerReplies": "Your replies",
"noConversationsYet": "No conversations yet"
"noConversationsYet": "No conversations yet",
"checklist": {
"toggle": "To-do list",
"addItem": "Add item",
"itemPlaceholder": "List item",
"emptyHint": "No items yet — add one below.",
"removeItem": "Remove item",
"progress": "{{done}} / {{total}}"
}
},
"executiveMeetings": {
"title": "Meetings list",
+332 -33
View File
@@ -19,6 +19,7 @@ import {
Inbox,
Mail,
MessageCircle,
ListTodo,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -47,7 +48,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import {
type ChecklistItem,
type Note,
type NoteKind,
type NoteLabel,
type NoteThread,
type ReceivedNote,
@@ -642,10 +645,24 @@ function NoteCard({
{note.title}
</div>
)}
{note.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
{note.content}
</div>
{note.kind === "checklist" ? (
<ChecklistView
items={note.items ?? []}
testIdPrefix={`note-card-checklist-${note.id}`}
className="mt-1"
onToggle={(itemId, done) => {
const next = (note.items ?? []).map((it) =>
it.id === itemId ? { ...it, done } : it,
);
update.mutate({ id: note.id, kind: "checklist", items: next });
}}
/>
) : (
note.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
{note.content}
</div>
)
)}
</div>
<button
@@ -771,10 +788,18 @@ function ReceivedList({
{n.title}
</div>
)}
{n.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
{n.content}
</div>
{n.kind === "checklist" ? (
<ChecklistView
items={n.items ?? []}
testIdPrefix={`received-note-checklist-${n.id}`}
className="mt-1"
/>
) : (
n.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
{n.content}
</div>
)
)}
</button>
))}
@@ -822,10 +847,18 @@ function SentList({
{n.title}
</div>
)}
{n.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
{n.content}
</div>
{n.kind === "checklist" ? (
<ChecklistView
items={n.items ?? []}
testIdPrefix={`sent-note-checklist-${n.id}`}
className="mt-1"
/>
) : (
n.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
{n.content}
</div>
)
)}
<div className="text-[11px] text-muted-foreground mt-2">
{t("notes.to", "To:")}{" "}
@@ -1191,10 +1224,17 @@ function ThreadDialog({
<Mail size={12} />
{t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
</div>
{thread.content && (
<div className="text-sm text-foreground/85 whitespace-pre-wrap">
{thread.content}
</div>
{thread.kind === "checklist" ? (
<ChecklistView
items={thread.items ?? []}
testIdPrefix={`thread-checklist-${thread.id}`}
/>
) : (
thread.content && (
<div className="text-sm text-foreground/85 whitespace-pre-wrap">
{thread.content}
</div>
)
)}
{(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && (
<div className="border-t pt-3">
@@ -1413,6 +1453,199 @@ function GroupedReplies({
);
}
function newItemId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `it_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
}
function ChecklistEditor({
items,
onChange,
testIdPrefix,
}: {
items: ChecklistItem[];
onChange: (next: ChecklistItem[]) => void;
testIdPrefix: string;
}) {
const { t } = useTranslation();
const inputRefs = useRef<Map<string, HTMLInputElement | null>>(new Map());
const focusItemIdRef = useRef<string | null>(null);
// After append (Enter or Add), shift focus to the freshly inserted row.
useEffect(() => {
const id = focusItemIdRef.current;
if (!id) return;
const el = inputRefs.current.get(id);
if (el) {
el.focus();
focusItemIdRef.current = null;
}
}, [items]);
const setItem = (id: string, patch: Partial<ChecklistItem>) => {
onChange(items.map((it) => (it.id === id ? { ...it, ...patch } : it)));
};
const removeItem = (id: string) => {
onChange(items.filter((it) => it.id !== id));
};
const appendItem = () => {
const item: ChecklistItem = { id: newItemId(), text: "", done: false };
focusItemIdRef.current = item.id;
onChange([...items, item]);
};
return (
<div className="space-y-1.5" data-testid={`${testIdPrefix}-list`}>
{items.length === 0 && (
<div className="text-xs text-muted-foreground italic px-1">
{t("notes.checklist.emptyHint", "No items yet — add one below.")}
</div>
)}
{items.map((it) => (
<div
key={it.id}
className="flex items-center gap-2"
data-testid={`${testIdPrefix}-item-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
onChange={(e) => setItem(it.id, { done: e.target.checked })}
className="h-4 w-4 rounded border-black/30 cursor-pointer accent-amber-500"
aria-label={t("notes.checklist.itemPlaceholder", "List item")}
data-testid={`${testIdPrefix}-check-${it.id}`}
/>
<Input
ref={(el) => {
if (el) inputRefs.current.set(it.id, el);
else inputRefs.current.delete(it.id);
}}
value={it.text}
onChange={(e) => setItem(it.id, { text: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
appendItem();
} else if (
e.key === "Backspace" &&
it.text.length === 0 &&
items.length > 1
) {
e.preventDefault();
removeItem(it.id);
}
}}
placeholder={t("notes.checklist.itemPlaceholder", "List item")}
className={`h-8 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 ${
it.done ? "line-through text-muted-foreground" : ""
}`}
data-testid={`${testIdPrefix}-text-${it.id}`}
/>
<button
type="button"
onClick={() => removeItem(it.id)}
className="p-1 rounded text-muted-foreground hover:text-destructive"
aria-label={t("notes.checklist.removeItem", "Remove item")}
data-testid={`${testIdPrefix}-remove-${it.id}`}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={appendItem}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1 py-1"
data-testid={`${testIdPrefix}-add`}
>
<Plus size={12} />
{t("notes.checklist.addItem", "Add item")}
</button>
</div>
);
}
function ChecklistView({
items,
onToggle,
testIdPrefix,
className = "",
}: {
items: ChecklistItem[];
onToggle?: (id: string, done: boolean) => void;
testIdPrefix: string;
className?: string;
}) {
if (items.length === 0) return null;
return (
<div
className={`space-y-1 ${className}`}
data-testid={`${testIdPrefix}-view`}
onClick={(e) => {
// Prevent the parent card's click handler (which opens edit) when
// toggling a checkbox is the intended action.
if (onToggle) e.stopPropagation();
}}
>
{items.map((it) => (
<label
key={it.id}
className="flex items-start gap-2 text-sm cursor-pointer"
data-testid={`${testIdPrefix}-row-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
disabled={!onToggle}
onChange={(e) => onToggle?.(it.id, e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500 disabled:cursor-default"
data-testid={`${testIdPrefix}-check-${it.id}`}
/>
<span
className={`flex-1 break-words ${
it.done ? "line-through text-muted-foreground" : ""
}`}
>
{it.text || "\u00A0"}
</span>
</label>
))}
</div>
);
}
function KindToggle({
kind,
onChange,
testId,
}: {
kind: NoteKind;
onChange: (k: NoteKind) => void;
testId?: string;
}) {
const { t } = useTranslation();
const next: NoteKind = kind === "text" ? "checklist" : "text";
return (
<button
type="button"
onClick={() => onChange(next)}
data-testid={testId}
aria-pressed={kind === "checklist"}
aria-label={t("notes.checklist.toggle", "To-do list")}
title={t("notes.checklist.toggle", "To-do list")}
className={`p-1 rounded-md hover:bg-black/5 ${
kind === "checklist"
? "text-amber-600 bg-amber-100/60"
: "text-muted-foreground"
}`}
>
<ListTodo size={15} />
</button>
);
}
function ColorPicker({
value,
onChange,
@@ -1531,6 +1764,8 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
const [content, setContent] = useState("");
const [color, setColor] = useState("default");
const [labelIds, setLabelIds] = useState<number[]>([]);
const [kind, setKind] = useState<NoteKind>("text");
const [items, setItems] = useState<ChecklistItem[]>([]);
const create = useCreateNote();
const ref = useRef<HTMLDivElement>(null);
@@ -1557,23 +1792,41 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, title, content, color, labelIds]);
}, [open, title, content, color, labelIds, kind, items]);
const reset = () => {
setTitle("");
setContent("");
setColor("default");
setLabelIds([]);
setKind("text");
setItems([]);
setOpen(false);
};
const save = () => {
if (!title.trim() && !content.trim()) {
const cleanItems =
kind === "checklist"
? items
.map((it) => ({ ...it, text: it.text.trim() }))
.filter((it) => it.text.length > 0)
: [];
const isEmpty =
!title.trim() &&
(kind === "checklist" ? cleanItems.length === 0 : !content.trim());
if (isEmpty) {
reset();
return;
}
create.mutate(
{ title: title.trim(), content: content.trim(), color, labelIds },
{
title: title.trim(),
content: kind === "checklist" ? "" : content.trim(),
color,
labelIds,
kind,
items: kind === "checklist" ? cleanItems : null,
},
{ onSuccess: reset },
);
};
@@ -1596,15 +1849,30 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold"
data-testid="notes-composer-title"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
data-testid="notes-composer-content"
/>
{kind === "checklist" ? (
<div className="mt-2 px-1">
<ChecklistEditor
items={items}
onChange={setItems}
testIdPrefix="notes-composer-checklist"
/>
</div>
) : (
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
data-testid="notes-composer-content"
/>
)}
<div className="flex items-center gap-1 mt-2">
<ColorPicker value={color} onChange={setColor} />
<KindToggle
kind={kind}
onChange={setKind}
testId="notes-composer-kind-toggle"
/>
<LabelMenu
labels={labels}
selected={labelIds}
@@ -1644,11 +1912,27 @@ function EditNoteDialog({
const [content, setContent] = useState(note.content);
const [color, setColor] = useState(note.color);
const [labelIds, setLabelIds] = useState<number[]>(note.labelIds);
const [kind, setKind] = useState<NoteKind>(note.kind ?? "text");
const [items, setItems] = useState<ChecklistItem[]>(note.items ?? []);
const update = useUpdateNote();
const save = () => {
const cleanItems =
kind === "checklist"
? items
.map((it) => ({ ...it, text: it.text.trim() }))
.filter((it) => it.text.length > 0)
: [];
update.mutate(
{ id: note.id, title: title.trim(), content: content.trim(), color, labelIds },
{
id: note.id,
title: title.trim(),
content: kind === "checklist" ? "" : content.trim(),
color,
labelIds,
kind,
items: kind === "checklist" ? cleanItems : null,
},
{ onSuccess: onClose },
);
};
@@ -1673,14 +1957,29 @@ function EditNoteDialog({
placeholder={t("notes.titlePlaceholder", "Title")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold text-base"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[160px]"
/>
{kind === "checklist" ? (
<div className="px-1 min-h-[160px]" data-testid="notes-edit-checklist-wrap">
<ChecklistEditor
items={items}
onChange={setItems}
testIdPrefix="notes-edit-checklist"
/>
</div>
) : (
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[160px]"
/>
)}
<div className="flex items-center gap-1 mt-2">
<ColorPicker value={color} onChange={setColor} />
<KindToggle
kind={kind}
onChange={setKind}
testId="notes-edit-kind-toggle"
/>
<LabelMenu labels={labels} selected={labelIds} onChange={setLabelIds} />
<div className="ms-auto">
<Button size="sm" onClick={save}>
@@ -0,0 +1,227 @@
// E2E regression for the per-note checklist (to-do list) option.
// 1) Composer can switch to checklist mode, items can be added and saved.
// 2) After reload, the saved checklist persists with its items + done state.
// 3) Sending a checklist note delivers the items to the recipient snapshot.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run this UI test");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix, displayEn) {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username };
}
test.afterAll(async () => {
if (createdUserIds.length > 0) {
await pool.query(
`DELETE FROM note_recipients WHERE sender_user_id = ANY($1::int[]) OR recipient_user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
async function loginViaUi(page, username) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(TEST_PASSWORD);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
test("composer creates a checklist note that persists across reloads", async ({
page,
}) => {
const user = await createUser("notes_checklist_e2e", "Checklist Tester");
await loginViaUi(page, user.username);
await page.goto("/notes");
await expect(page.getByTestId("notes-page")).toBeVisible();
const title = `checklist-test ${uniq()}`;
// Open composer, set title, switch to checklist.
await page.getByTestId("notes-composer").click();
await page.getByTestId("notes-composer-title").fill(title);
await page.getByTestId("notes-composer-kind-toggle").click();
// Add two items via the "Add item" button.
const composer = page.getByTestId("notes-composer");
await composer.getByTestId("notes-composer-checklist-add").click();
// The newly inserted row gets focus; type into it.
await page.keyboard.type("Buy milk");
await composer.getByTestId("notes-composer-checklist-add").click();
await page.keyboard.type("Walk the dog");
// Save via the explicit Save button (synchronous, no portal-click risk).
const savePromise = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === "/api/notes" &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId("notes-composer-save").click();
const resp = await savePromise;
const note = await resp.json();
expect(note.title).toBe(title);
expect(note.kind).toBe("checklist");
expect(Array.isArray(note.items)).toBe(true);
expect(note.items.map((i) => i.text)).toEqual(["Buy milk", "Walk the dog"]);
// The note card should render the checklist with both rows visible.
const card = page.getByTestId(`note-card-${note.id}`);
await expect(card).toBeVisible();
await expect(card.getByText("Buy milk")).toBeVisible();
await expect(card.getByText("Walk the dog")).toBeVisible();
// Tick the first item from the card, expect a PATCH that flips done.
const firstItemId = note.items[0].id;
const togglePromise = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await card
.getByTestId(`note-card-checklist-${note.id}-check-${firstItemId}`)
.click();
const patched = await togglePromise;
const patchedNote = await patched.json();
expect(patchedNote.kind).toBe("checklist");
const patchedFirst = patchedNote.items.find((i) => i.id === firstItemId);
expect(patchedFirst?.done).toBe(true);
// Reload — the persisted checklist with the toggled state must come back.
await page.reload();
await expect(page.getByTestId(`note-card-${note.id}`)).toBeVisible();
const reloadedFirst = page.getByTestId(
`note-card-checklist-${note.id}-check-${firstItemId}`,
);
await expect(reloadedFirst).toBeChecked();
});
test("PATCH items without kind normalizes against the note's current kind", async ({
page,
}) => {
const user = await createUser("notes_checklist_norm", "Norm Tester");
await loginViaUi(page, user.username);
// Create a plain text note (kind defaults to "text").
const textResp = await page.request.post("/api/notes", {
data: { title: `text-note ${uniq()}`, content: "hello" },
});
expect(textResp.ok()).toBe(true);
const textNote = await textResp.json();
expect(textNote.kind).toBe("text");
// Patch only `items` (no kind). Server must coerce items back to null
// because the note's current kind is "text".
const patchTextResp = await page.request.patch(`/api/notes/${textNote.id}`, {
data: { items: [{ id: "x", text: "should be dropped", done: false }] },
});
expect(patchTextResp.ok()).toBe(true);
const patchedText = await patchTextResp.json();
expect(patchedText.kind).toBe("text");
expect(patchedText.items).toBeNull();
// Now create a checklist note and patch only `items` (no kind). Items
// must persist because the note's current kind is "checklist".
const clResp = await page.request.post("/api/notes", {
data: {
title: `cl-note ${uniq()}`,
kind: "checklist",
items: [{ id: "a", text: "first", done: false }],
},
});
const cl = await clResp.json();
const patchClResp = await page.request.patch(`/api/notes/${cl.id}`, {
data: { items: [{ id: "a", text: "first", done: true }] },
});
expect(patchClResp.ok()).toBe(true);
const patchedCl = await patchClResp.json();
expect(patchedCl.kind).toBe("checklist");
expect(patchedCl.items[0].done).toBe(true);
});
test("checklist notes are delivered to recipients with their items", async ({
page,
}) => {
const sender = await createUser("notes_checklist_send_s", "Sender");
const recipient = await createUser("notes_checklist_send_r", "Recipient");
await loginViaUi(page, sender.username);
// Create the checklist note directly via the API (we already cover the
// composer flow above; this test focuses on the send/snapshot path).
const createResp = await page.request.post("/api/notes", {
data: {
title: `send-checklist ${uniq()}`,
kind: "checklist",
items: [
{ id: "a", text: "Item A", done: false },
{ id: "b", text: "Item B", done: true },
],
},
});
expect(createResp.ok()).toBe(true);
const note = await createResp.json();
const sendResp = await page.request.post(`/api/notes/${note.id}/send`, {
data: { recipientUserIds: [recipient.id] },
});
expect(sendResp.ok()).toBe(true);
// Log out and back in as the recipient, then read the inbox.
await page.request.post("/api/auth/logout").catch(() => {});
await loginViaUi(page, recipient.username);
const receivedResp = await page.request.get("/api/notes/received?archived=false");
expect(receivedResp.ok()).toBe(true);
const received = await receivedResp.json();
const delivered = received.find((r) => r.id === note.id);
expect(delivered, "checklist note should appear in recipient inbox").toBeTruthy();
expect(delivered.kind).toBe("checklist");
expect(delivered.items.map((i) => i.text)).toEqual(["Item A", "Item B"]);
expect(delivered.items[1].done).toBe(true);
});
+11 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
import { pgTable, text, serial, timestamp, integer, varchar, boolean, jsonb, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { usersTable } from "./users";
@@ -13,6 +13,8 @@ export const noteFoldersTable = pgTable("note_folders", {
userNameUnique: uniqueIndex("note_folders_user_name_unique").on(t.userId, t.name),
}));
export type ChecklistItem = { id: string; text: string; done: boolean };
export const notesTable = pgTable("notes", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
@@ -20,6 +22,10 @@ export const notesTable = pgTable("notes", {
title: varchar("title", { length: 300 }).notNull().default(""),
content: text("content").notNull().default(""),
color: varchar("color", { length: 32 }).notNull().default("default"),
// "text" (plain note) or "checklist" (per-note to-do list). Items live in
// `items` for checklist notes; for text notes `items` is null.
kind: varchar("kind", { length: 16 }).notNull().default("text"),
items: jsonb("items").$type<ChecklistItem[]>(),
isPinned: boolean("is_pinned").notNull().default(false),
isArchived: boolean("is_archived").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -65,6 +71,10 @@ export const noteRecipientsTable = pgTable("note_recipients", {
title: varchar("title", { length: 300 }).notNull().default(""),
content: text("content").notNull().default(""),
color: varchar("color", { length: 32 }).notNull().default("default"),
// Snapshot of the source note's kind/items at send time so checklist
// notes survive (and stay independent of) sender edits/deletes.
kind: varchar("kind", { length: 16 }).notNull().default("text"),
items: jsonb("items").$type<ChecklistItem[]>(),
status: varchar("status", { length: 16 }).notNull().default("unread"),
sentAt: timestamp("sent_at", { withTimezone: true }).notNull().defaultNow(),
readAt: timestamp("read_at", { withTimezone: true }),