Implement share-link service (task 30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 14:09:10 -05:00
co-authored by Claude Sonnet 4.6
parent 28475e483d
commit d5deee9a46
7 changed files with 222 additions and 0 deletions
+2
View File
@@ -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";
+24
View File
@@ -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",
{
+113
View File
@@ -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);
}