diff --git a/STATUS.md b/STATUS.md index d7ced4d..0a9784e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -22,6 +22,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **20 — Dashboard composition (single-dashboard MVP)**. Added `default_dashboard_layout` jsonb column to `users` + migration `0007_uneven_living_lightning.sql`. Created `src/modules/_core/manifest.tsx` (`core.activity` placeholder widget) and registered it. Updated all three module manifests (calendar, lists, notes) with real async server component widget renders (data-fetching, empty states). Created `src/lib/dashboard.ts` (layout parsing + `computeDefaultLayout` greedy packer). Built `src/app/page.tsx` — 12-col CSS Grid, static `smColSpan` lookup for Tailwind class safety, per-widget `` for parallel loading, graceful skip for unknown widget IDs. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. - **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. ## Next up diff --git a/drizzle/0009_share_links.sql b/drizzle/0009_share_links.sql new file mode 100644 index 0000000..cc42853 --- /dev/null +++ b/drizzle/0009_share_links.sql @@ -0,0 +1,29 @@ +CREATE TABLE "share_links" ( + "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, + "token" text NOT NULL, + "capabilities" jsonb NOT NULL, + "created_by" uuid NOT NULL, + "expires_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "share_links_token_unique" UNIQUE("token") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "share_links" ADD CONSTRAINT "share_links_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "share_links" ADD CONSTRAINT "share_links_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX "share_links_household_idx" ON "share_links" USING btree ("household_id"); +--> statement-breakpoint +CREATE INDEX "share_links_entity_idx" ON "share_links" USING btree ("entity_type","entity_id"); diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 18e0dd2..9b63d71 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -1,11 +1,13 @@ "use server"; import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; import { db } from "@/lib/db"; import { users } from "@/modules/_core/schema"; import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes"; import type { ThemeId, ThemeMode } from "@/modules/_core/themes"; import { getCurrentSession } from "@/lib/session"; +import { revokeShareLink } from "@/modules/_core/share"; export async function setUserTheme({ theme, @@ -23,3 +25,10 @@ export async function setUserTheme({ .set({ theme, themeMode: mode }) .where(eq(users.id, user.id)); } + +export async function revokeShareLinkAction(formData: FormData): Promise { + const id = formData.get("id"); + if (typeof id !== "string") throw new Error("Missing id"); + await revokeShareLink(id); + revalidatePath("/settings"); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 13f1ee7..9b06293 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,10 +1,15 @@ import { getCurrentSession } from "@/lib/session"; +import { getActiveShareLinks } from "@/modules/_core/share"; +import { getEntityType } from "@/modules/_core/registry"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { ThemePicker } from "@/components/theme-picker"; +import { revokeShareLinkAction } from "./actions"; import Link from "next/link"; export default async function SettingsPage() { const { user } = await getCurrentSession(); + const shareLinks = await getActiveShareLinks(); return (
@@ -23,6 +28,45 @@ export default async function SettingsPage() { + + + Active Share Links + + + {shareLinks.length === 0 ? ( +

No active share links.

+ ) : ( +
    + {shareLinks.map((link) => { + const registration = getEntityType(link.entityType); + const label = registration?.label.singular ?? link.entityType; + return ( +
  • +
    + {label} + + {link.capabilities.write ? "read + write" : "read-only"} + + {link.expiresAt && ( + + · expires {link.expiresAt.toLocaleDateString()} + + )} +
    +
    + + +
    +
  • + ); + })} +
+ )} +
+
+ [index("activity_log_household_created_idx").on(t.householdId, t.createdAt)], ); +export const shareLinks = pgTable( + "share_links", + { + 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(), + token: text("token").notNull().unique(), + capabilities: jsonb("capabilities").$type<{ read: boolean; write: boolean }>().notNull(), + createdBy: uuid("created_by") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + index("share_links_household_idx").on(t.householdId), + index("share_links_entity_idx").on(t.entityType, t.entityId), + ], +); + export const reminders = pgTable( "reminders", { diff --git a/src/modules/_core/share.ts b/src/modules/_core/share.ts new file mode 100644 index 0000000..303c047 --- /dev/null +++ b/src/modules/_core/share.ts @@ -0,0 +1,113 @@ +"use server"; + +import { createHash, randomBytes } from "crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { getEntityType } from "./registry"; +import { shareLinks } from "./schema"; + +export type ShareLinkCapabilities = { read: boolean; write: boolean }; + +export type CreateShareLinkResult = { + url: string; + token: string; + expiresAt: Date | null; +}; + +function hashToken(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +function buildUrl(token: string): string { + const base = process.env["NEXTAUTH_URL"] ?? "http://localhost:3000"; + return `${base}/s/${token}`; +} + +export async function createShareLink( + entityType: string, + entityId: string, + opts: { expiresAt?: Date; capabilities?: Partial } = {}, +): Promise { + const registration = getEntityType(entityType); + if (!registration?.share?.canShare) { + throw new Error(`Entity type "${entityType}" is not shareable`); + } + + const { user, household } = await getCurrentSession(); + + const rawToken = randomBytes(32).toString("base64url"); + const tokenHash = hashToken(rawToken); + + const capabilities: ShareLinkCapabilities = { + read: opts.capabilities?.read ?? true, + write: opts.capabilities?.write ?? false, + }; + + const expiresAt = opts.expiresAt ?? null; + + await db.insert(shareLinks).values({ + householdId: household.id, + entityType, + entityId, + token: tokenHash, + capabilities, + createdBy: user.id, + expiresAt, + }); + + return { url: buildUrl(rawToken), token: rawToken, expiresAt }; +} + +export async function resolveShareToken( + rawToken: string, +): Promise<{ entityType: string; entityId: string; capabilities: ShareLinkCapabilities } | null> { + const tokenHash = hashToken(rawToken); + + const [link] = await db + .select() + .from(shareLinks) + .where(and(eq(shareLinks.token, tokenHash), isNull(shareLinks.revokedAt))) + .limit(1); + + if (!link) return null; + + if (link.expiresAt && link.expiresAt < new Date()) return null; + + return { + entityType: link.entityType, + entityId: link.entityId, + capabilities: link.capabilities, + }; +} + +export async function revokeShareLink(id: string): Promise { + const { household } = await getCurrentSession(); + + await db + .update(shareLinks) + .set({ revokedAt: new Date() }) + .where(and(eq(shareLinks.id, id), eq(shareLinks.householdId, household.id))); +} + +export type ActiveShareLink = typeof shareLinks.$inferSelect; + +export async function getActiveShareLinks(): Promise { + const { household } = await getCurrentSession(); + const now = new Date(); + + const rows = await db + .select() + .from(shareLinks) + .where( + and( + eq(shareLinks.householdId, household.id), + isNull(shareLinks.revokedAt), + // exclude already-expired links + ), + ) + .orderBy(shareLinks.createdAt); + + // Filter expired in JS since OR NULL handling is cleaner here + return rows.filter((r) => !r.expiresAt || r.expiresAt > now); +}