Implement share-link service (task 30)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
28475e483d
commit
d5deee9a46
@@ -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 `<Suspense>` 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
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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<void> {
|
||||
const id = formData.get("id");
|
||||
if (typeof id !== "string") throw new Error("Missing id");
|
||||
await revokeShareLink(id);
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="container max-w-2xl py-8 space-y-6">
|
||||
@@ -23,6 +28,45 @@ export default async function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Share Links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{shareLinks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active share links.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{shareLinks.map((link) => {
|
||||
const registration = getEntityType(link.entityType);
|
||||
const label = registration?.label.singular ?? link.entityType;
|
||||
return (
|
||||
<li key={link.id} className="flex items-center justify-between gap-4 text-sm">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{link.capabilities.write ? "read + write" : "read-only"}
|
||||
</span>
|
||||
{link.expiresAt && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
· expires {link.expiresAt.toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<form action={revokeShareLinkAction}>
|
||||
<input type="hidden" name="id" value={link.id} />
|
||||
<Button variant="destructive" size="sm" type="submit">
|
||||
Revoke
|
||||
</Button>
|
||||
</form>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Link
|
||||
href="/settings/household"
|
||||
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||
|
||||
@@ -13,3 +13,5 @@ export type {
|
||||
export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry";
|
||||
export type { QuickAddItem, SerializedQuickAddItem } from "./registry";
|
||||
export { logActivity } from "./activity";
|
||||
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
||||
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
||||
|
||||
@@ -106,6 +106,30 @@ export const activityLog = pgTable(
|
||||
(t) => [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",
|
||||
{
|
||||
|
||||
@@ -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<ShareLinkCapabilities> } = {},
|
||||
): Promise<CreateShareLinkResult> {
|
||||
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<void> {
|
||||
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<ActiveShareLink[]> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user