Add notes module

This commit is contained in:
ginnoir
2026-05-06 04:10:50 -05:00
parent 016d01cc25
commit 5b434702ba
19 changed files with 775 additions and 24 deletions
+4 -1
View File
@@ -4,6 +4,8 @@ Self-hosted family coordination web app for Matt and his wife. Replaces a commer
This file is the canonical brief. Read it at the start of every session before making changes. Sub-task briefs in [`docs/tasks/`](docs/tasks/) reference this file; do not duplicate its contents there.
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
---
## Goals
@@ -67,7 +69,7 @@ src/
- **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity.
- **Feature flags.** Env/db-driven for staging experiments.
**Rule for contributors (including future Sonnet sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead.
**Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead.
---
@@ -142,6 +144,7 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
- **One module = one PR/commit boundary** when possible.
- **Tests:** Vitest for units (where it pays off), Playwright for one happy-path E2E per module. Don't write tests for trivial CRUD.
- **Secrets** via `.env` (gitignored) and `.env.example` (committed, no values).
- **Teardown after validation.** After build/test/E2E validation, stop any dev servers or compose services started for the task unless the user explicitly asks to keep them running. This avoids stale instances and port conflicts in later sessions.
---
+4 -1
View File
@@ -4,6 +4,8 @@ Self-hosted family coordination web app for Matt and his wife. Replaces a commer
This file is the canonical brief. Read it at the start of every session before making changes. Sub-task briefs in [`docs/tasks/`](docs/tasks/) reference this file; do not duplicate its contents there.
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
---
## Goals
@@ -67,7 +69,7 @@ src/
- **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity.
- **Feature flags.** Env/db-driven for staging experiments.
**Rule for contributors (including future Sonnet sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead.
**Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead.
---
@@ -142,6 +144,7 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
- **One module = one PR/commit boundary** when possible.
- **Tests:** Vitest for units (where it pays off), Playwright for one happy-path E2E per module. Don't write tests for trivial CRUD.
- **Secrets** via `.env` (gitignored) and `.env.example` (committed, no values).
- **Teardown after validation.** After build/test/E2E validation, stop any dev servers or compose services started for the task unless the user explicitly asks to keep them running. This avoids stale instances and port conflicts in later sessions.
---
+4 -3
View File
@@ -1,6 +1,6 @@
# Status
Living progress tracker. Update at the end of each task. The canonical brief is [`CLAUDE.md`](CLAUDE.md); task briefs live in [`docs/tasks/`](docs/tasks/).
Living progress tracker. Update at the end of each task. Codex and Claude Code both work on this project, so write status notes and next-step instructions for either agent to resume. Canonical briefs are [`AGENTS.md`](AGENTS.md) and [`CLAUDE.md`](CLAUDE.md); keep them synchronized. Task briefs live in [`docs/tasks/`](docs/tasks/).
## Done
@@ -18,10 +18,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
- **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
- **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass.
## Next up
- **12 — Notes module** (or next task in `docs/tasks/`).
- Next task in `docs/tasks/`.
## Development login/testing notes
@@ -41,7 +42,7 @@ Living progress tracker. Update at the end of each task. The canonical brief is
## How to resume in a fresh session
1. Open the repo root in VS Code.
2. Tell Claude: _"Read [CLAUDE.md](CLAUDE.md) and [STATUS.md](STATUS.md), then complete [docs/tasks/03-drizzle-postgres.md](docs/tasks/03-drizzle-postgres.md). Stop at the acceptance criteria."_
2. Tell Codex or Claude Code: _"Read [AGENTS.md](AGENTS.md), [CLAUDE.md](CLAUDE.md), and [STATUS.md](STATUS.md), then complete the next task in [docs/tasks/](docs/tasks/). Stop at the acceptance criteria."_
3. After it lands, append the result to the **Done** section here, bump **Next up**, and commit.
## Environment notes
+12
View File
@@ -76,6 +76,18 @@ $env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json'
pnpm test:e2e
```
After validation, tear down services started for the test run unless you are intentionally keeping the app open:
```powershell
# Stop a manually started Next dev server if one is still running on port 3000.
Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique |
ForEach-Object { Stop-Process -Id $_ }
# Stop the local database container when the session is finished.
docker compose -f docker-compose.dev.yaml down
```
## Production Removal Plan
Before production deployment, complete the checklist below.
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE "reminders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"entity_type" text NOT NULL,
"entity_id" uuid NOT NULL,
"fire_at" timestamp with time zone NOT NULL,
"channel" text DEFAULT 'in_app' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"author_id" uuid NOT NULL,
"title" text NOT NULL,
"body" text DEFAULT '' NOT NULL,
"pinned" boolean DEFAULT false NOT NULL,
"remind_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "reminders_entity_unique" ON "reminders" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "reminders_household_fire_at_idx" ON "reminders" USING btree ("household_id","fire_at");--> statement-breakpoint
CREATE INDEX "notes_household_pinned_updated_idx" ON "notes" USING btree ("household_id","pinned","updated_at");--> statement-breakpoint
CREATE INDEX "notes_author_idx" ON "notes" USING btree ("author_id");
+11
View File
@@ -0,0 +1,11 @@
import { notFound } from "next/navigation";
import { NoteEditor } from "@/modules/notes/components/note-editor";
import { getNote } from "@/modules/notes/server/queries";
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const note = await getNote(id).catch(() => null);
if (!note) notFound();
return <NoteEditor note={note} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { NoteEditor } from "@/modules/notes/components/note-editor";
export default function NewNotePage() {
return <NoteEditor />;
}
+7
View File
@@ -0,0 +1,7 @@
import { NotesIndex } from "@/modules/notes/components/notes-index";
import { listNotes } from "@/modules/notes/server/queries";
export default async function NotesPage() {
const notes = await listNotes();
return <NotesIndex notes={notes} />;
}
+2 -1
View File
@@ -3,9 +3,10 @@ import postgres from "postgres";
import * as coreSchema from "@/modules/_core/schema";
import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
import * as notesSchema from "@/modules/notes/schema";
const client = postgres(process.env["DATABASE_URL"]!);
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema };
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema };
export const db = drizzle(client, { schema });
+21
View File
@@ -1,11 +1,13 @@
import type { AdapterAccountType } from "@auth/core/adapters";
import {
index,
integer,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
@@ -82,3 +84,22 @@ export const householdMembers = pgTable(
},
(t) => [primaryKey({ columns: [t.householdId, t.userId] })],
);
export const reminders = pgTable(
"reminders",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id").notNull(),
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
channel: text("channel").notNull().default("in_app"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex("reminders_entity_unique").on(t.entityType, t.entityId),
index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt),
],
);
@@ -0,0 +1,88 @@
import type { ReactNode } from "react";
export function MarkdownPreview({ markdown }: { markdown: string }) {
const blocks = parseBlocks(markdown);
if (blocks.length === 0) {
return <p className="text-sm text-muted-foreground">No preview yet.</p>;
}
return (
<div className="grid gap-3 text-sm leading-6">
{blocks.map((block, index) => {
if (block.type === "heading") {
return (
<h2 key={index} className="text-lg font-semibold">
{block.text}
</h2>
);
}
if (block.type === "list") {
return (
<ul key={index} className="list-disc space-y-1 pl-5">
{block.items.map((item, itemIndex) => (
<li key={itemIndex}>{item}</li>
))}
</ul>
);
}
return <p key={index}>{block.text}</p>;
})}
</div>
);
}
type MarkdownBlock =
| { type: "heading"; text: ReactNode }
| { type: "list"; items: ReactNode[] }
| { type: "paragraph"; text: ReactNode };
function parseBlocks(markdown: string): MarkdownBlock[] {
const blocks: MarkdownBlock[] = [];
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
let paragraph: string[] = [];
let listItems: string[] = [];
function flushParagraph() {
if (paragraph.length === 0) return;
blocks.push({ type: "paragraph", text: paragraph.join(" ") });
paragraph = [];
}
function flushList() {
if (listItems.length === 0) return;
blocks.push({ type: "list", items: listItems.map((item) => item) });
listItems = [];
}
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
flushParagraph();
flushList();
continue;
}
if (line.startsWith("# ")) {
flushParagraph();
flushList();
blocks.push({ type: "heading", text: line.slice(2).trim() });
continue;
}
if (line.startsWith("- ")) {
flushParagraph();
listItems.push(line.slice(2).trim());
continue;
}
flushList();
paragraph.push(line);
}
flushParagraph();
flushList();
return blocks;
}
@@ -0,0 +1,134 @@
"use client";
import { useRouter } from "next/navigation";
import { Pin, PinOff, Save, Trash2 } from "lucide-react";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { NoteDto } from "../server/queries";
import { createNote, deleteNote, setNotePinned, updateNote } from "../server/actions";
import { MarkdownPreview } from "./markdown-preview";
export function NoteEditor({ note }: { note?: NoteDto }) {
const router = useRouter();
const [currentNote, setCurrentNote] = useState(note);
const [title, setTitle] = useState(note?.title ?? "");
const [body, setBody] = useState(note?.body ?? "");
const [remindAt, setRemindAt] = useState(toLocalDateTimeValue(note?.remindAt ?? null));
const [isPending, startTransition] = useTransition();
const pinned = currentNote?.pinned ?? false;
function saveNote() {
startTransition(async () => {
if (currentNote) {
const updated = await updateNote({
id: currentNote.id,
title,
body,
remindAt: remindAt ? new Date(remindAt) : null,
});
setCurrentNote({
...updated,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
remindAt: updated.remindAt?.toISOString() ?? null,
});
return;
}
const created = await createNote({
title,
body,
remindAt: remindAt ? new Date(remindAt) : null,
});
router.push(`/notes/${created.id}`);
});
}
function togglePinned() {
if (!currentNote) return;
startTransition(async () => {
setCurrentNote(await setNotePinned({ id: currentNote.id, pinned: !pinned }));
});
}
function removeNote() {
if (!currentNote) return;
startTransition(async () => {
await deleteNote({ id: currentNote.id });
router.push("/notes");
});
}
return (
<div className="mx-auto grid w-full max-w-6xl gap-5 p-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">{currentNote ? currentNote.title : "New note"}</h1>
<p className="text-sm text-muted-foreground">Markdown notes shared with the household.</p>
</div>
<div className="flex flex-wrap gap-2">
{currentNote ? (
<Button variant="outline" onClick={togglePinned} disabled={isPending}>
{pinned ? <PinOff /> : <Pin />}
{pinned ? "Unpin note" : "Pin note"}
</Button>
) : null}
{currentNote ? (
<Button variant="destructive" onClick={removeNote} disabled={isPending}>
<Trash2 />
Delete note
</Button>
) : null}
<Button onClick={saveNote} disabled={!title.trim() || isPending}>
<Save />
Save note
</Button>
</div>
</header>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)]">
<section className="grid gap-4 rounded-lg border bg-background p-4">
<div className="space-y-1.5">
<Label htmlFor="note-title">Title</Label>
<Input id="note-title" value={title} onChange={(event) => setTitle(event.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="note-body">Body</Label>
<textarea
id="note-body"
aria-label="Body"
className="min-h-80 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm leading-6 outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="# Dinner ideas&#10;&#10;- Tacos&#10;- Soup"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="note-reminder">Reminder</Label>
<Input
id="note-reminder"
type="datetime-local"
value={remindAt}
onChange={(event) => setRemindAt(event.target.value)}
/>
</div>
</section>
<aside className="rounded-lg border bg-card p-4 text-card-foreground">
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Preview</h2>
<MarkdownPreview markdown={body} />
</aside>
</div>
</div>
);
}
function toLocalDateTimeValue(value: string | null) {
if (!value) return "";
const date = new Date(value);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
@@ -0,0 +1,52 @@
import Link from "next/link";
import { Plus, Pin } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import type { NoteDto } from "../server/queries";
export function NotesIndex({ notes }: { notes: NoteDto[] }) {
return (
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Notes</h1>
<p className="text-sm text-muted-foreground">
Shared reminders, reference notes, and loose household details.
</p>
</div>
<Link href="/notes/new" className={buttonVariants()}>
<Plus />
New note
</Link>
</div>
{notes.length === 0 ? (
<div className="rounded-lg border bg-background p-8 text-center text-sm text-muted-foreground">
No notes yet.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{notes.map((note) => (
<Link
key={note.id}
href={`/notes/${note.id}`}
className="grid min-h-32 gap-3 rounded-lg border bg-card p-4 text-card-foreground transition-colors hover:bg-muted"
>
<div className="flex items-start justify-between gap-3">
<h2 className="font-medium">{note.title}</h2>
{note.pinned ? (
<Pin aria-label="Pinned" className="mt-0.5 size-4 shrink-0 text-primary" />
) : null}
</div>
<p className="line-clamp-3 text-sm text-muted-foreground">
{note.body || "No body text."}
</p>
<div className="text-xs text-muted-foreground">
Updated {new Date(note.updatedAt).toLocaleDateString()}
</div>
</Link>
))}
</div>
)}
</div>
);
}
-18
View File
@@ -1,18 +0,0 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "notes",
name: "Notes",
nav: { href: "/notes", label: "Notes", icon: "file-text" },
entities: [
{
type: "notes.note",
label: { singular: "Note", plural: "Notes" },
resolveUrl: (id) => `/notes/${id}`,
},
],
dashboardWidgets: [],
quickAdds: [],
};
export default manifest;
+56
View File
@@ -0,0 +1,56 @@
import type { ModuleManifest } from "../_core/module";
import { z } from "zod";
import { searchNotes } from "./server/queries";
const notesWidgetConfigSchema = z.object({
filter: z.enum(["pinned", "all"]),
limit: z.number().int().min(1).max(50).optional(),
});
const manifest: ModuleManifest = {
id: "notes",
name: "Notes",
nav: { href: "/notes", label: "Notes", icon: "file-text" },
entities: [
{
type: "notes.note",
label: { singular: "Note", plural: "Notes" },
share: { canShare: true, defaultCapabilities: ["read"] },
reminder: { canRemind: true },
search: { search: searchNotes },
resolveUrl: (id) => `/notes/${id}`,
},
],
dashboardWidgets: [
{
id: "notes.filtered",
title: "Notes",
description: "Pinned notes or recent notes.",
category: "Notes",
defaultSize: { w: 4, h: 3 },
minSize: { w: 3, h: 2 },
defaultPriority: 40,
configSchema: notesWidgetConfigSchema,
defaultConfig: { filter: "pinned", limit: 10 },
resolveConfigOptions: async () => undefined,
render: ({ config }) => {
const parsed = notesWidgetConfigSchema.parse(config);
return (
<div className="text-sm text-muted-foreground">
{parsed.filter === "pinned" ? "Pinned notes" : "Notes"}
</div>
);
},
},
],
quickAdds: [
{
id: "notes.new-note",
label: "New note",
icon: "file-plus",
action: () => undefined,
},
],
};
export default manifest;
+27
View File
@@ -0,0 +1,27 @@
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const notes = pgTable(
"notes",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
body: text("body").notNull().default(""),
pinned: boolean("pinned").notNull().default(false),
remindAt: timestamp("remind_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("notes_household_pinned_updated_idx").on(t.householdId, t.pinned, t.updatedAt),
index("notes_author_idx").on(t.authorId),
],
);
export type Note = typeof notes.$inferSelect;
+142
View File
@@ -0,0 +1,142 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { reminders } from "@/modules/_core/schema";
import { notes } from "../schema";
import { canAccessNote, getNote } from "./queries";
const noteInput = z.object({
title: z.string().trim().min(1).max(200),
body: z.string().max(20000).default(""),
pinned: z.boolean().default(false),
remindAt: z.coerce.date().nullable().optional(),
});
const updateNoteInput = z.object({
id: z.string().uuid(),
title: noteInput.shape.title.optional(),
body: z.string().max(20000).optional(),
pinned: z.boolean().optional(),
remindAt: z.coerce.date().nullable().optional(),
});
export async function createNote(input: z.input<typeof noteInput>) {
const parsed = noteInput.parse(input);
const { household, user } = await getCurrentSession();
const [note] = await db.transaction(async (tx) => {
const [created] = await tx
.insert(notes)
.values({
householdId: household.id,
authorId: user.id,
title: parsed.title,
body: parsed.body,
pinned: parsed.pinned,
remindAt: parsed.remindAt ?? null,
})
.returning();
if (!created) throw new Error("Note was not created");
await syncNoteReminder(tx, {
householdId: household.id,
noteId: created.id,
remindAt: created.remindAt,
});
return [created];
});
revalidatePath("/notes");
return note;
}
export async function updateNote(input: z.input<typeof updateNoteInput>) {
const parsed = updateNoteInput.parse(input);
const { household } = await getCurrentSession();
await assertCanAccessNote(parsed.id, household.id);
const [note] = await db.transaction(async (tx) => {
const [updated] = await tx
.update(notes)
.set({
title: parsed.title,
body: parsed.body,
pinned: parsed.pinned,
remindAt: parsed.remindAt === undefined ? undefined : parsed.remindAt,
updatedAt: new Date(),
})
.where(eq(notes.id, parsed.id))
.returning();
if (!updated) throw new Error("Note was not updated");
if (parsed.remindAt !== undefined) {
await syncNoteReminder(tx, {
householdId: household.id,
noteId: updated.id,
remindAt: updated.remindAt,
});
}
return [updated];
});
revalidatePath("/notes");
revalidatePath(`/notes/${parsed.id}`);
return note;
}
export async function setNotePinned(input: { id: string; pinned: boolean }) {
const parsed = z.object({ id: z.string().uuid(), pinned: z.boolean() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessNote(parsed.id, household.id);
await db
.update(notes)
.set({ pinned: parsed.pinned, updatedAt: new Date() })
.where(eq(notes.id, parsed.id));
revalidatePath("/notes");
revalidatePath(`/notes/${parsed.id}`);
return getNote(parsed.id);
}
export async function deleteNote(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessNote(parsed.id, household.id);
await db.transaction(async (tx) => {
await tx
.delete(reminders)
.where(and(eq(reminders.entityType, "notes.note"), eq(reminders.entityId, parsed.id)));
await tx.delete(notes).where(eq(notes.id, parsed.id));
});
revalidatePath("/notes");
}
async function assertCanAccessNote(noteId: string, householdId: string) {
if (!(await canAccessNote(noteId, householdId))) throw new Error("Forbidden");
}
async function syncNoteReminder(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
input: { householdId: string; noteId: string; remindAt: Date | null },
) {
await tx
.delete(reminders)
.where(and(eq(reminders.entityType, "notes.note"), eq(reminders.entityId, input.noteId)));
if (!input.remindAt) return;
await tx.insert(reminders).values({
householdId: input.householdId,
entityType: "notes.note",
entityId: input.noteId,
fireAt: input.remindAt,
channel: "in_app",
});
}
+112
View File
@@ -0,0 +1,112 @@
"use server";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { notes } from "../schema";
export type NoteDto = {
id: string;
householdId: string;
authorId: string;
title: string;
body: string;
pinned: boolean;
remindAt: string | null;
createdAt: string;
updatedAt: string;
};
export async function listNotes(): Promise<NoteDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select()
.from(notes)
.where(eq(notes.householdId, household.id))
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
return rows.map(toNoteDto);
}
export async function getNote(id: string): Promise<NoteDto> {
const parsed = z.string().uuid().parse(id);
const { household } = await getCurrentSession();
const [note] = await db
.select()
.from(notes)
.where(and(eq(notes.id, parsed), eq(notes.householdId, household.id)))
.limit(1);
if (!note) throw new Error("Note not found");
return toNoteDto(note);
}
export async function canAccessNote(noteId: string, householdId: string) {
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.id, noteId), eq(notes.householdId, householdId)))
.limit(1);
return !!note;
}
export async function searchNotes(query: string, householdId: string) {
const rows = await db
.select({
id: notes.id,
title: notes.title,
body: notes.body,
})
.from(notes)
.where(
and(
eq(notes.householdId, householdId),
or(sql`${notes.title} ilike ${`%${query}%`}`, sql`${notes.body} ilike ${`%${query}%`}`),
),
)
.limit(10);
return rows.map((row) => ({
id: row.id,
title: row.title,
url: `/notes/${row.id}`,
excerpt: row.body.slice(0, 160),
}));
}
export async function listWidgetNotes(input: { filter: "pinned" | "all"; limit?: number }) {
const parsed = z
.object({
filter: z.enum(["pinned", "all"]),
limit: z.number().int().min(1).max(50).optional(),
})
.parse(input);
const { household } = await getCurrentSession();
const conditions = [eq(notes.householdId, household.id)];
if (parsed.filter === "pinned") conditions.push(eq(notes.pinned, true));
const rows = await db
.select()
.from(notes)
.where(and(...conditions))
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.limit(parsed.limit ?? 10);
return rows.map(toNoteDto);
}
function toNoteDto(note: typeof notes.$inferSelect): NoteDto {
return {
id: note.id,
householdId: note.householdId,
authorId: note.authorId,
title: note.title,
body: note.body,
pinned: note.pinned,
remindAt: note.remindAt?.toISOString() ?? null,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
};
}
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";
import postgres from "postgres";
const databaseUrl = process.env["DATABASE_URL"] ?? "postgres://famapp:famapp@localhost:5432/famapp";
test("notes happy path", async ({ page }) => {
const suffix = Date.now().toString();
const title = `E2E Note ${suffix}`;
const editedTitle = `E2E Updated ${suffix}`;
const body = `# Heading ${suffix}
- Milk
- Bread
<script>window.__famappInjected = true</script>`;
const reminder = "2026-05-20T09:30";
await page.goto("/notes");
await expect(page.getByRole("heading", { name: "Notes" })).toBeVisible();
await page.getByRole("link", { name: "New note" }).click();
await page.getByLabel("Title").fill(title);
await page.getByLabel("Body").fill(body);
await page.getByLabel("Reminder").fill(reminder);
await page.getByRole("button", { name: "Save note" }).click();
await expect(page).toHaveURL(/\/notes\/[0-9a-f-]+$/);
await expect(page.getByRole("heading", { name: new RegExp(title) })).toBeVisible();
await expect(page.getByRole("heading", { name: `Heading ${suffix}` })).toBeVisible();
await expect(page.getByRole("complementary").getByText("Milk")).toBeVisible();
await expect(page.locator("script", { hasText: "window.__famappInjected" })).toHaveCount(0);
await expect
.poll(() => page.evaluate(() => Reflect.get(window, "__famappInjected")))
.toBeUndefined();
await page.getByRole("button", { name: "Pin note" }).click();
await expect(page.getByRole("button", { name: "Unpin note" })).toBeVisible();
await page.getByLabel("Title").fill(editedTitle);
await page.getByRole("button", { name: "Save note" }).click();
await expect(page.getByRole("heading", { name: new RegExp(editedTitle) })).toBeVisible();
const noteId = new URL(page.url()).pathname.split("/").pop();
expect(noteId).toBeTruthy();
const sql = postgres(databaseUrl);
try {
const reminders = await sql`
select entity_type, entity_id, fire_at
from reminders
where entity_type = 'notes.note' and entity_id = ${noteId}
`;
expect(reminders).toHaveLength(1);
expect(reminders[0]?.entity_type).toBe("notes.note");
} finally {
await sql.end();
}
await page.goto("/notes");
await expect(page.getByRole("link", { name: new RegExp(editedTitle) })).toBeVisible();
await page.getByRole("link", { name: new RegExp(editedTitle) }).click();
await page.getByRole("button", { name: "Delete note" }).click();
await expect(page).toHaveURL(/\/notes$/);
await expect(page.getByRole("link", { name: new RegExp(editedTitle) })).toBeHidden();
});