diff --git a/STATUS.md b/STATUS.md index 0a9784e..7925455 100644 --- a/STATUS.md +++ b/STATUS.md @@ -23,6 +23,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. - **22 — Activity log**. Added `activity_log` table to `_core/schema.ts` with index on `(household_id, created_at desc)`. Migration `0008_activity_log.sql` applied. `logActivity()` server function in `_core/activity.ts` reads current session and inserts a row. Added `ActivityLogEntry` type and optional `renderActivity?(entry): string` to `EntityTypeRegistration` in `_core/module.ts`. All three module manifests implement `renderActivity` for each entity type (human-readable, no hardcoded branches in the widget). Replaced `core.activity` widget stub with a real async server component that queries the last 20 rows via `getEntityType(entry.entityType)?.renderActivity(entry)`. Wired `logActivity()` into every create/update/delete in calendar, lists, and notes server actions. Also added `text` to `getAuthorizedItem` select so toggle/delete log the item text. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. - **30 — Share-link service**. Added `share_links` table to `_core/schema.ts` + migration `0009_share_links.sql`. Created `_core/share.ts` with `createShareLink`, `resolveShareToken`, `revokeShareLink`, and `getActiveShareLinks`. Token is 32 random bytes (URL-safe base64), stored as SHA-256 hash — raw token only returned at creation. `createShareLink` guards that the entity type is registered with `canShare === true`. `resolveShareToken` returns null for expired or revoked tokens. All three functions exported from `_core/index.ts`. `/settings` page gained a Share links card: lists active links (entity label, read/write capabilities, expiry) with a Revoke button per link (server action in `settings/actions.ts`). `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. +- **31 — Public share viewer**. Made `actorId` nullable in `activity_log` (migration `0010_nullable_actor_id.sql`, `onDelete: "set null"`) for anonymous share-page mutations. Added `logShareActivity` to `_core/activity.ts` (no session, explicit `householdId`). Added `householdId` to `resolveShareToken` return. Added `renderSharedView` to `EntityTypeRegistration` type. Each module implements `loadForShare` (bare DB queries, no session) and `renderSharedView`: calendar shows upcoming 90-day events or single-event details, lists shows items with optional toggle, notes shows title + body. `toggleShareListItem` server action lives in `lists/server/share-actions.ts` — validates token write capability, verifies item→list→household chain, logs `share.toggle` with `actorId = null`. `/app/s/[token]/page.tsx` resolves token, dispatches to `loadForShare` + `renderSharedView`, returns friendly error for invalid/expired tokens, sets `noindex`. Middleware `/s/*` exemption confirmed present. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. ## Next up diff --git a/drizzle/0010_nullable_actor_id.sql b/drizzle/0010_nullable_actor_id.sql new file mode 100644 index 0000000..a19cb1c --- /dev/null +++ b/drizzle/0010_nullable_actor_id.sql @@ -0,0 +1,5 @@ +ALTER TABLE "activity_log" ALTER COLUMN "actor_id" DROP NOT NULL; +--> statement-breakpoint +ALTER TABLE "activity_log" DROP CONSTRAINT "activity_log_actor_id_users_id_fk"; +--> statement-breakpoint +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; diff --git a/src/app/s/[token]/page.tsx b/src/app/s/[token]/page.tsx new file mode 100644 index 0000000..14f7738 --- /dev/null +++ b/src/app/s/[token]/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { resolveShareToken } from "@/modules/_core/share"; +import { getEntityType } from "@/modules/_core/registry"; + +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + +export default async function SharePage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + + const resolved = await resolveShareToken(token); + if (!resolved) return ; + + const entityReg = getEntityType(resolved.entityType); + if (!entityReg?.loadForShare || !entityReg.renderSharedView) { + return ; + } + + const data = await entityReg.loadForShare(resolved.entityId); + if (!data) return ; + + return ( +
+
+

+ Shared via famapp + {resolved.capabilities.write ? " · You can edit this" : " · View only"} +

+
+ {entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })} +
+ ); +} + +function ShareError({ message }: { message?: string }) { + return ( +
+

Link not found

+

+ {message ?? + "This share link may have expired or been revoked. Ask the sender for a new link."} +

+
+ ); +} diff --git a/src/modules/_core/activity.ts b/src/modules/_core/activity.ts index 61b7ad7..730625b 100644 --- a/src/modules/_core/activity.ts +++ b/src/modules/_core/activity.ts @@ -18,3 +18,20 @@ export async function logActivity(input: { payload: input.payload ?? null, }); } + +export async function logShareActivity(input: { + householdId: string; + entityType: string; + entityId: string; + action: string; + payload?: Record; +}): Promise { + await db.insert(activityLog).values({ + householdId: input.householdId, + entityType: input.entityType, + entityId: input.entityId, + actorId: null, + action: input.action, + payload: input.payload ?? null, + }); +} diff --git a/src/modules/_core/index.ts b/src/modules/_core/index.ts index 46f0cd2..a064904 100644 --- a/src/modules/_core/index.ts +++ b/src/modules/_core/index.ts @@ -12,6 +12,6 @@ export type { } from "./module"; export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry"; export type { QuickAddItem, SerializedQuickAddItem } from "./registry"; -export { logActivity } from "./activity"; +export { logActivity, logShareActivity } from "./activity"; export { createShareLink, resolveShareToken, revokeShareLink } from "./share"; export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share"; diff --git a/src/modules/_core/module.ts b/src/modules/_core/module.ts index 639e1c6..64f41a6 100644 --- a/src/modules/_core/module.ts +++ b/src/modules/_core/module.ts @@ -6,7 +6,7 @@ export type ActivityLogEntry = { householdId: string; entityType: string; entityId: string; - actorId: string; + actorId: string | null; action: string; payload: Record | null; createdAt: Date; @@ -69,6 +69,11 @@ export type EntityTypeRegistration = { search?: SearchAdapter; resolveUrl: (id: string) => string; loadForShare?: (id: string) => Promise; + renderSharedView?: (props: { + data: unknown; + capabilities: { read: boolean; write: boolean }; + token: string; + }) => ReactNode; renderActivity?: (entry: ActivityLogEntry) => string; }; diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index 1d23982..ed9582a 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -97,8 +97,7 @@ export const activityLog = pgTable( entityType: text("entity_type").notNull(), entityId: uuid("entity_id").notNull(), actorId: uuid("actor_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + .references(() => users.id, { onDelete: "set null" }), action: text("action").notNull(), payload: jsonb("payload").$type | null>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/src/modules/_core/share.ts b/src/modules/_core/share.ts index 303c047..bf3b8c6 100644 --- a/src/modules/_core/share.ts +++ b/src/modules/_core/share.ts @@ -61,7 +61,12 @@ export async function createShareLink( export async function resolveShareToken( rawToken: string, -): Promise<{ entityType: string; entityId: string; capabilities: ShareLinkCapabilities } | null> { +): Promise<{ + entityType: string; + entityId: string; + capabilities: ShareLinkCapabilities; + householdId: string; +} | null> { const tokenHash = hashToken(rawToken); const [link] = await db @@ -78,6 +83,7 @@ export async function resolveShareToken( entityType: link.entityType, entityId: link.entityId, capabilities: link.capabilities, + householdId: link.householdId, }; } diff --git a/src/modules/calendar/components/shared-view.tsx b/src/modules/calendar/components/shared-view.tsx new file mode 100644 index 0000000..b25bed2 --- /dev/null +++ b/src/modules/calendar/components/shared-view.tsx @@ -0,0 +1,79 @@ +import { MapPin, StickyNote } from "lucide-react"; +import type { CalendarShareData, EventShareData } from "../server/share-queries"; + +function formatEventTime(startAt: string, endAt: string, allDay: boolean): string { + const start = new Date(startAt); + const end = new Date(endAt); + if (allDay) { + return start.toLocaleDateString(undefined, { weekday: "short", month: "long", day: "numeric" }); + } + const dateStr = start.toLocaleDateString(undefined, { + weekday: "short", + month: "long", + day: "numeric", + }); + const startTime = start.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + const endTime = end.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + return `${dateStr} · ${startTime}–${endTime}`; +} + +export function EventSharedView({ data }: { data: EventShareData }) { + return ( +
+
+

+ {data.calendarName} +

+

{data.title}

+

+ {formatEventTime(data.startAt, data.endAt, data.allDay)} +

+
+ {data.location && ( +
+ + {data.location} +
+ )} + {data.notes && ( +
+ +

{data.notes}

+
+ )} +
+ ); +} + +export function CalendarSharedView({ data }: { data: CalendarShareData }) { + return ( +
+
+

{data.name}

+

+ Upcoming events — next 90 days +

+
+ {data.events.length === 0 ? ( +

No upcoming events.

+ ) : ( +
    + {data.events.map((event) => ( +
  • + {event.title} + + {formatEventTime(event.startAt, event.endAt, event.allDay)} + + {event.location && ( + + + {event.location} + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/modules/calendar/manifest.tsx b/src/modules/calendar/manifest.tsx index 82c0fa3..5e5b53d 100644 --- a/src/modules/calendar/manifest.tsx +++ b/src/modules/calendar/manifest.tsx @@ -1,6 +1,13 @@ import type { ModuleManifest, WidgetContext } from "../_core/module"; import { z } from "zod"; import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries"; +import { + loadCalendarForShare, + loadEventForShare, + type CalendarShareData, + type EventShareData, +} from "./server/share-queries"; +import { CalendarSharedView, EventSharedView } from "./components/shared-view"; const calendarIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]); @@ -101,6 +108,8 @@ const manifest: ModuleManifest = { share: { canShare: true, defaultCapabilities: ["read"] }, search: { search: searchCalendars }, resolveUrl: (id) => `/calendar?id=${id}`, + loadForShare: (id) => loadCalendarForShare(id), + renderSharedView: ({ data }) => , renderActivity: (entry) => { const name = entry.payload?.name as string | undefined; if (entry.action === "create") return `Created calendar${name ? ` "${name}"` : ""}`; @@ -115,6 +124,8 @@ const manifest: ModuleManifest = { reminder: { canRemind: true }, search: { search: searchEvents }, resolveUrl: (id) => `/calendar/events/${id}`, + loadForShare: (id) => loadEventForShare(id), + renderSharedView: ({ data }) => , renderActivity: (entry) => { const title = entry.payload?.title as string | undefined; if (entry.action === "create") return `Created event${title ? ` "${title}"` : ""}`; diff --git a/src/modules/calendar/server/share-queries.ts b/src/modules/calendar/server/share-queries.ts new file mode 100644 index 0000000..940e792 --- /dev/null +++ b/src/modules/calendar/server/share-queries.ts @@ -0,0 +1,90 @@ +import { and, asc, eq, gte, lte } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { calendarEvents, calendars } from "../schema"; + +export type EventSummary = { + id: string; + title: string; + startAt: string; + endAt: string; + allDay: boolean; + location: string | null; + notes: string | null; +}; + +export type CalendarShareData = { + id: string; + name: string; + color: string | null; + events: EventSummary[]; +}; + +export type EventShareData = EventSummary & { calendarName: string }; + +export async function loadCalendarForShare(id: string): Promise { + const [calendar] = await db + .select({ id: calendars.id, name: calendars.name, color: calendars.color }) + .from(calendars) + .where(eq(calendars.id, id)) + .limit(1); + + if (!calendar) return null; + + const now = new Date(); + const end = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000); + + const rows = await db + .select({ + id: calendarEvents.id, + title: calendarEvents.title, + startAt: calendarEvents.startAt, + endAt: calendarEvents.endAt, + allDay: calendarEvents.allDay, + location: calendarEvents.location, + notes: calendarEvents.notes, + }) + .from(calendarEvents) + .where( + and( + eq(calendarEvents.calendarId, id), + gte(calendarEvents.startAt, now), + lte(calendarEvents.startAt, end), + ), + ) + .orderBy(asc(calendarEvents.startAt)); + + return { + ...calendar, + events: rows.map((e) => ({ + ...e, + startAt: e.startAt.toISOString(), + endAt: e.endAt.toISOString(), + })), + }; +} + +export async function loadEventForShare(id: string): Promise { + const [row] = await db + .select({ + id: calendarEvents.id, + title: calendarEvents.title, + startAt: calendarEvents.startAt, + endAt: calendarEvents.endAt, + allDay: calendarEvents.allDay, + location: calendarEvents.location, + notes: calendarEvents.notes, + calendarName: calendars.name, + }) + .from(calendarEvents) + .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) + .where(eq(calendarEvents.id, id)) + .limit(1); + + if (!row) return null; + + return { + ...row, + startAt: row.startAt.toISOString(), + endAt: row.endAt.toISOString(), + }; +} diff --git a/src/modules/lists/components/shared-view.tsx b/src/modules/lists/components/shared-view.tsx new file mode 100644 index 0000000..af4ef00 --- /dev/null +++ b/src/modules/lists/components/shared-view.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useOptimistic, useTransition } from "react"; +import type { ListShareData, ListShareItem } from "../server/share-queries"; +import { toggleShareListItem } from "../server/share-actions"; + +export function ListSharedView({ + data, + canWrite, + token, +}: { + data: ListShareData; + canWrite: boolean; + token: string; +}) { + const [optimisticItems, setOptimisticItems] = useOptimistic( + data.items, + (current: ListShareItem[], { id, done }: { id: string; done: boolean }) => + current.map((item) => (item.id === id ? { ...item, done } : item)), + ); + const [, startTransition] = useTransition(); + + function toggle(item: ListShareItem) { + if (!canWrite) return; + const next = !item.done; + startTransition(async () => { + setOptimisticItems({ id: item.id, done: next }); + await toggleShareListItem(token, item.id); + }); + } + + const open = optimisticItems.filter((i) => !i.done); + const done = optimisticItems.filter((i) => i.done); + + return ( +
+
+

{data.name}

+

{data.type}

+
+ + {optimisticItems.length === 0 ? ( +

This list is empty.

+ ) : ( +
+ {open.length > 0 && ( +
    + {open.map((item) => ( + + ))} +
+ )} + {done.length > 0 && ( +
+ + {done.length} completed + +
    + {done.map((item) => ( + + ))} +
+
+ )} +
+ )} +
+ ); +} + +function ItemRow({ + item, + canWrite, + onToggle, +}: { + item: ListShareItem; + canWrite: boolean; + onToggle: (item: ListShareItem) => void; +}) { + return ( +
  • + {canWrite ? ( + onToggle(item)} + /> + ) : ( + + )} + + {item.text} + {item.qty && ( + ×{item.qty} + )} + +
  • + ); +} diff --git a/src/modules/lists/manifest.tsx b/src/modules/lists/manifest.tsx index ec462e8..3b28468 100644 --- a/src/modules/lists/manifest.tsx +++ b/src/modules/lists/manifest.tsx @@ -1,6 +1,8 @@ import type { ModuleManifest, WidgetContext } from "../_core/module"; import { z } from "zod"; import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries"; +import { loadListForShare, type ListShareData } from "./server/share-queries"; +import { ListSharedView } from "./components/shared-view"; const listIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]); @@ -54,6 +56,14 @@ const manifest: ModuleManifest = { share: { canShare: true, defaultCapabilities: ["read", "write"] }, search: { search: searchLists }, resolveUrl: (id) => `/lists/${id}`, + loadForShare: (id) => loadListForShare(id), + renderSharedView: ({ data, capabilities, token }) => ( + + ), renderActivity: (entry) => { const name = entry.payload?.name as string | undefined; if (entry.action === "create") return `Created list${name ? ` "${name}"` : ""}`; diff --git a/src/modules/lists/server/share-actions.ts b/src/modules/lists/server/share-actions.ts new file mode 100644 index 0000000..82929b2 --- /dev/null +++ b/src/modules/lists/server/share-actions.ts @@ -0,0 +1,59 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { resolveShareToken } from "@/modules/_core/share"; +import { logShareActivity } from "@/modules/_core/activity"; +import { listItems, lists } from "../schema"; +import { notifyListChanged } from "./realtime"; + +export async function toggleShareListItem(rawToken: string, itemId: string): Promise { + z.string().min(1).parse(rawToken); + z.string().uuid().parse(itemId); + + const resolved = await resolveShareToken(rawToken); + if (!resolved || resolved.entityType !== "lists.list" || !resolved.capabilities.write) { + throw new Error("Invalid or read-only share token"); + } + + const listId = resolved.entityId; + + const [item] = await db + .select({ + id: listItems.id, + done: listItems.done, + text: listItems.text, + listId: listItems.listId, + }) + .from(listItems) + .innerJoin(lists, eq(listItems.listId, lists.id)) + .where( + and( + eq(listItems.id, itemId), + eq(listItems.listId, listId), + eq(lists.householdId, resolved.householdId), + ), + ) + .limit(1); + + if (!item) throw new Error("Item not found"); + + const done = !item.done; + await db + .update(listItems) + .set({ done, updatedAt: new Date() }) + .where(eq(listItems.id, itemId)); + + await logShareActivity({ + householdId: resolved.householdId, + entityType: "lists.item", + entityId: itemId, + action: "share.toggle", + payload: { done, text: item.text }, + }); + + revalidatePath(`/s/${rawToken}`); + await notifyListChanged(listId); +} diff --git a/src/modules/lists/server/share-queries.ts b/src/modules/lists/server/share-queries.ts new file mode 100644 index 0000000..acad1aa --- /dev/null +++ b/src/modules/lists/server/share-queries.ts @@ -0,0 +1,50 @@ +import { asc, eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { listItems, lists } from "../schema"; + +export type ListShareItem = { + id: string; + text: string; + done: boolean; + qty: string | null; + notes: string | null; + position: number; +}; + +export type ListShareData = { + id: string; + name: string; + type: string; + householdId: string; + items: ListShareItem[]; +}; + +export async function loadListForShare(id: string): Promise { + const [list] = await db + .select({ + id: lists.id, + name: lists.name, + type: lists.type, + householdId: lists.householdId, + }) + .from(lists) + .where(eq(lists.id, id)) + .limit(1); + + if (!list) return null; + + const items = await db + .select({ + id: listItems.id, + text: listItems.text, + done: listItems.done, + qty: listItems.qty, + notes: listItems.notes, + position: listItems.position, + }) + .from(listItems) + .where(eq(listItems.listId, id)) + .orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt)); + + return { ...list, items }; +} diff --git a/src/modules/notes/components/shared-view.tsx b/src/modules/notes/components/shared-view.tsx new file mode 100644 index 0000000..a871ad0 --- /dev/null +++ b/src/modules/notes/components/shared-view.tsx @@ -0,0 +1,28 @@ +import { Pin } from "lucide-react"; +import type { NoteShareData } from "../server/share-queries"; + +export function NoteSharedView({ data }: { data: NoteShareData }) { + const updatedAt = new Date(data.updatedAt).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( +
    +
    + {data.pinned && ( + + + Pinned + + )} +

    {data.title}

    +

    Updated {updatedAt}

    +
    + {data.body && ( +

    {data.body}

    + )} +
    + ); +} diff --git a/src/modules/notes/manifest.tsx b/src/modules/notes/manifest.tsx index 9b55df7..c17a7fa 100644 --- a/src/modules/notes/manifest.tsx +++ b/src/modules/notes/manifest.tsx @@ -1,6 +1,8 @@ import type { ModuleManifest, WidgetContext } from "../_core/module"; import { z } from "zod"; import { listWidgetNotes, searchNotes } from "./server/queries"; +import { loadNoteForShare, type NoteShareData } from "./server/share-queries"; +import { NoteSharedView } from "./components/shared-view"; const notesWidgetConfigSchema = z.object({ filter: z.enum(["pinned", "all"]), @@ -45,6 +47,8 @@ const manifest: ModuleManifest = { reminder: { canRemind: true }, search: { search: searchNotes }, resolveUrl: (id) => `/notes/${id}`, + loadForShare: (id) => loadNoteForShare(id), + renderSharedView: ({ data }) => , renderActivity: (entry) => { const title = entry.payload?.title as string | undefined; if (entry.action === "create") return `Created note${title ? ` "${title}"` : ""}`; diff --git a/src/modules/notes/server/share-queries.ts b/src/modules/notes/server/share-queries.ts new file mode 100644 index 0000000..b54d072 --- /dev/null +++ b/src/modules/notes/server/share-queries.ts @@ -0,0 +1,35 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { notes } from "../schema"; + +export type NoteShareData = { + id: string; + title: string; + body: string; + pinned: boolean; + updatedAt: string; +}; + +export async function loadNoteForShare(id: string): Promise { + const [note] = await db + .select({ + id: notes.id, + title: notes.title, + body: notes.body, + pinned: notes.pinned, + updatedAt: notes.updatedAt, + }) + .from(notes) + .where(eq(notes.id, id)) + .limit(1); + + if (!note) return null; + + return { + id: note.id, + title: note.title, + body: note.body, + pinned: note.pinned, + updatedAt: note.updatedAt.toISOString(), + }; +}