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.location && (
+
+
+ {data.location}
+
+ )}
+ {data.notes && (
+
+ )}
+
+ );
+}
+
+export function CalendarSharedView({ data }: { data: CalendarShareData }) {
+ return (
+
+
+ {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(),
+ };
+}