Journal dashboard widgets and quick-add; rich-text quick-add dialogs. Dashboard draft sync for live edit previews; assistant bubble + API tools. Journal UX: stress slider, mood grid, query cap fix.
134 lines
4.0 KiB
TypeScript
134 lines
4.0 KiB
TypeScript
import { createHash, randomBytes } from "crypto";
|
|
import { and, eq, isNull } from "drizzle-orm";
|
|
import type { ApiAuthContext } from "@/lib/api-auth";
|
|
import { db } from "@/lib/db";
|
|
import { getEntityType, getRegistry } from "./registry";
|
|
import { shareLinks } from "./schema";
|
|
import { ensureEntityShareAuthorized } from "./share-authorization";
|
|
import type { ShareLinkCapabilities } from "./share";
|
|
|
|
export type CreateShareLinkResult = {
|
|
url: string;
|
|
token: string;
|
|
expiresAt: Date | null;
|
|
};
|
|
|
|
export type EntityShareLink = {
|
|
id: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
createdAt: string;
|
|
expiresAt: string | null;
|
|
capabilities: ShareLinkCapabilities;
|
|
};
|
|
|
|
function hashToken(raw: string): string {
|
|
return createHash("sha256").update(raw).digest("hex");
|
|
}
|
|
|
|
function buildUrl(token: string): string {
|
|
const base =
|
|
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
|
|
return `${base}/s/${token}`;
|
|
}
|
|
|
|
export function listShareableEntityTypes() {
|
|
return getRegistry()
|
|
.entityTypes.filter((entity) => entity.share?.canShare)
|
|
.map((entity) => ({
|
|
type: entity.type,
|
|
label: entity.label.singular,
|
|
defaultCapabilities: entity.share?.defaultCapabilities ?? ["read"],
|
|
}));
|
|
}
|
|
|
|
export async function createShareLinkForScope(
|
|
scope: ApiAuthContext,
|
|
entityType: string,
|
|
entityId: string,
|
|
opts: {
|
|
expiresAt?: Date | null;
|
|
capabilities?: Partial<ShareLinkCapabilities>;
|
|
} = {},
|
|
): Promise<CreateShareLinkResult> {
|
|
const registration = getEntityType(entityType);
|
|
if (!registration?.share?.canShare) {
|
|
throw new Error(`Entity type "${entityType}" is not shareable`);
|
|
}
|
|
|
|
if (!scope.userId) {
|
|
throw new Error("Share links require an authenticated user session");
|
|
}
|
|
|
|
await ensureEntityShareAuthorized(registration, entityId, {
|
|
householdId: scope.householdId,
|
|
userId: scope.userId,
|
|
});
|
|
|
|
const rawToken = randomBytes(32).toString("base64url");
|
|
const tokenHash = hashToken(rawToken);
|
|
const capabilities: ShareLinkCapabilities = {
|
|
read: opts.capabilities?.read ?? true,
|
|
write: opts.capabilities?.write ?? false,
|
|
};
|
|
|
|
await db.insert(shareLinks).values({
|
|
householdId: scope.householdId,
|
|
entityType,
|
|
entityId,
|
|
token: tokenHash,
|
|
capabilities,
|
|
createdBy: scope.userId,
|
|
expiresAt: opts.expiresAt ?? null,
|
|
});
|
|
|
|
return { url: buildUrl(rawToken), token: rawToken, expiresAt: opts.expiresAt ?? null };
|
|
}
|
|
|
|
export async function revokeShareLinkForScope(scope: ApiAuthContext, id: string): Promise<void> {
|
|
const [row] = await db
|
|
.select({ id: shareLinks.id })
|
|
.from(shareLinks)
|
|
.where(and(eq(shareLinks.id, id), eq(shareLinks.householdId, scope.householdId)))
|
|
.limit(1);
|
|
|
|
if (!row) throw new Error("Share link not found");
|
|
|
|
await db.update(shareLinks).set({ revokedAt: new Date() }).where(eq(shareLinks.id, id));
|
|
}
|
|
|
|
export async function listShareLinksForScope(
|
|
scope: ApiAuthContext,
|
|
filters?: { entityType?: string; entityId?: string },
|
|
): Promise<EntityShareLink[]> {
|
|
const now = new Date();
|
|
const conditions = [eq(shareLinks.householdId, scope.householdId), isNull(shareLinks.revokedAt)];
|
|
|
|
if (filters?.entityType) conditions.push(eq(shareLinks.entityType, filters.entityType));
|
|
if (filters?.entityId) conditions.push(eq(shareLinks.entityId, filters.entityId));
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: shareLinks.id,
|
|
entityType: shareLinks.entityType,
|
|
entityId: shareLinks.entityId,
|
|
createdAt: shareLinks.createdAt,
|
|
expiresAt: shareLinks.expiresAt,
|
|
capabilities: shareLinks.capabilities,
|
|
})
|
|
.from(shareLinks)
|
|
.where(and(...conditions))
|
|
.orderBy(shareLinks.createdAt);
|
|
|
|
return rows
|
|
.filter((row) => !row.expiresAt || row.expiresAt > now)
|
|
.map((row) => ({
|
|
id: row.id,
|
|
entityType: row.entityType,
|
|
entityId: row.entityId,
|
|
createdAt: row.createdAt.toISOString(),
|
|
expiresAt: row.expiresAt?.toISOString() ?? null,
|
|
capabilities: row.capabilities,
|
|
}));
|
|
}
|