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
@@ -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