Files
community-rule/lib/create/api.ts
T
2026-05-19 22:16:08 -06:00

715 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CreateFlowState } from "../../app/(app)/create/types";
import { migrateLegacyCreateFlowState } from "./migrateLegacyCreateFlowState";
const jsonHeaders = { "Content-Type": "application/json" };
async function parseJson<T>(response: Response): Promise<T> {
const data = (await response.json()) as T;
return data;
}
/** Supports legacy `{ error: string }` and `{ error: { message: string } }` from API routes. */
function readApiErrorMessage(data: unknown): string {
if (!data || typeof data !== "object" || !("error" in data)) {
return "Request failed";
}
const err = (data as { error: unknown }).error;
if (typeof err === "string") {
return err;
}
if (err && typeof err === "object" && "message" in err) {
const m = (err as { message: unknown }).message;
if (typeof m === "string") {
return m;
}
}
return "Request failed";
}
export async function fetchAuthSession(): Promise<{
user: { id: string; email: string } | null;
}> {
const res = await fetch("/api/auth/session", {
credentials: "include",
});
if (!res.ok) {
return { user: null };
}
return parseJson(res);
}
export async function requestMagicLink(
email: string,
nextPath?: string,
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
const res = await fetch("/api/auth/magic-link/request", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({
email,
...(nextPath ? { next: nextPath } : {}),
}),
});
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
if (!res.ok) {
return {
ok: false,
error: readApiErrorMessage(data),
retryAfterMs:
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
};
}
return { ok: true };
}
export async function logout(): Promise<void> {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
});
}
/** CR-103: send verify link to `newEmail` for the signed-in user. */
export async function requestEmailChange(
newEmail: string,
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
const res = await fetch("/api/user/email-change/request", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ newEmail }),
});
const data: unknown = await res.json().catch(() => ({}));
if (!res.ok) {
let retryAfterMs: number | undefined;
if (
res.status === 429 &&
data &&
typeof data === "object" &&
"details" in data
) {
const d = (data as { details?: { retryAfterMs?: unknown } }).details;
if (d && typeof d.retryAfterMs === "number") {
retryAfterMs = d.retryAfterMs;
}
}
return {
ok: false,
error: readApiErrorMessage(data),
retryAfterMs,
};
}
return { ok: true };
}
export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
const res = await fetch("/api/drafts/me", { credentials: "include" });
if (res.status === 401) return null;
if (!res.ok) return null;
const data = await parseJson<{ draft: { payload: unknown } | null }>(res);
if (!data.draft?.payload || typeof data.draft.payload !== "object") {
return null;
}
return migrateLegacyCreateFlowState(
data.draft.payload as Record<string, unknown>,
);
}
const DRAFT_SAVE_NETWORK_ERROR =
"Something went wrong. Check your connection and try again.";
const PUBLISH_FAILED_FALLBACK =
"Something went wrong. Check your connection or try again.";
/** Parse JSON body; empty or invalid bodies return `null` (avoids `response.json()` throws). */
async function safeParseJsonResponse(res: Response): Promise<unknown> {
const text = await res.text();
const trimmed = text.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as unknown;
} catch {
return null;
}
}
export type SaveDraftResult =
| { ok: true }
| { ok: false; message: string; status?: number };
async function errorBodyMessage(res: Response): Promise<string> {
try {
const data: unknown = await res.json();
const msg = readApiErrorMessage(data);
if (msg !== "Request failed") return msg;
} catch {
/* non-JSON body */
}
const statusText = res.statusText?.trim();
if (statusText) return statusText;
return "Save failed";
}
/**
* Wipe the signed-in user's saved draft. Fire-and-forget: any non-2xx (including
* the sync-flag-off `503` and the unauthenticated `401`) is swallowed because
* callers only invoke this on already-published / explicitly-discarded flows
* where a leftover server draft is acceptable.
*/
export async function deleteServerDraft(): Promise<void> {
try {
await fetch("/api/drafts/me", {
method: "DELETE",
credentials: "include",
});
} catch {
/* ignore — server draft cleanup is best-effort */
}
}
export async function saveDraftToServer(
state: CreateFlowState,
): Promise<SaveDraftResult> {
try {
const res = await fetch("/api/drafts/me", {
method: "PUT",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ payload: state }),
});
if (res.ok) {
return { ok: true as const };
}
const message = await errorBodyMessage(res);
return {
ok: false as const,
message,
status: res.status,
};
} catch {
return {
ok: false as const,
message: DRAFT_SAVE_NETWORK_ERROR,
};
}
}
export async function publishRule(input: {
title: string;
summary?: string;
document: Record<string, unknown>;
stakeholderEmails?: string[];
}): Promise<
| { ok: true; id: string; title: string }
| { ok: false; error: string; status?: number }
> {
try {
const res = await fetch("/api/rules", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({
title: input.title,
summary: input.summary,
document: input.document,
...(input.stakeholderEmails?.length
? { stakeholderEmails: input.stakeholderEmails }
: {}),
}),
});
const data = (await safeParseJsonResponse(res)) as {
error?: string | { message?: string };
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: res.statusText?.trim() || PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
};
}
}
export async function updatePublishedRule(
id: string,
input: {
title: string;
summary?: string | null;
document: Record<string, unknown>;
},
): Promise<{ ok: true } | { ok: false; error: string; status?: number }> {
try {
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({
title: input.title,
summary: input.summary ?? null,
document: input.document,
}),
});
const data = await safeParseJsonResponse(res);
if (!res.ok) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: res.statusText?.trim() || PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true as const };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
};
}
}
export type MyPublishedRule = {
id: string;
title: string;
summary: string | null;
createdAt: string;
updatedAt: string;
/** `owner` = authored rule; `stakeholder` = accepted invite (view only). */
role: "owner" | "stakeholder";
};
/**
* Lists the signed-in users published rules (**last updated first**, stable by id).
* Returns `null` on network failure or unauthenticated response.
*/
export async function fetchMyPublishedRules(): Promise<
MyPublishedRule[] | null
> {
try {
const res = await fetch("/api/rules/me", { credentials: "include" });
if (res.status === 401) return null;
if (!res.ok) return null;
const data = (await safeParseJsonResponse(res)) as {
rules?: MyPublishedRule[];
} | null;
if (!data || !Array.isArray(data.rules)) return null;
const rules = data.rules.filter(
(r): r is MyPublishedRule =>
r != null &&
typeof r === "object" &&
typeof (r as MyPublishedRule).id === "string" &&
typeof (r as MyPublishedRule).title === "string" &&
((r as MyPublishedRule).role === "owner" ||
(r as MyPublishedRule).role === "stakeholder"),
);
return rules;
} catch {
return null;
}
}
export type PublishedRuleDetailForClient = {
id: string;
title: string;
summary: string | null;
document: unknown;
};
export type FetchPublishedRuleDetailResult = {
rule: PublishedRuleDetailForClient;
viewerIsOwner: boolean;
};
/**
* Fetches a published rule for the browser (credentials included).
* Returns `null` on network failure or non-OK response.
*/
export async function fetchPublishedRuleDetail(
id: string,
): Promise<FetchPublishedRuleDetailResult | null> {
try {
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = (await safeParseJsonResponse(res)) as {
rule?: PublishedRuleDetailForClient;
viewerIsOwner?: unknown;
} | null;
if (
!data ||
!data.rule ||
typeof data.rule.id !== "string" ||
typeof data.rule.title !== "string" ||
typeof data.viewerIsOwner !== "boolean"
) {
return null;
}
return { rule: data.rule, viewerIsOwner: data.viewerIsOwner };
} catch {
return null;
}
}
export type RuleStakeholderListItem = {
id: string;
email: string;
invitedAt: string;
acceptedAt: string | null;
status: "pending" | "accepted";
};
function parseStakeholdersPayload(data: unknown): RuleStakeholderListItem[] | null {
if (!data || typeof data !== "object" || !("stakeholders" in data)) {
return null;
}
const raw = (data as { stakeholders: unknown }).stakeholders;
if (!Array.isArray(raw)) return null;
const out: RuleStakeholderListItem[] = [];
for (const x of raw) {
if (
!x ||
typeof x !== "object" ||
typeof (x as { id?: unknown }).id !== "string" ||
typeof (x as { email?: unknown }).email !== "string" ||
typeof (x as { invitedAt?: unknown }).invitedAt !== "string" ||
((x as { status?: unknown }).status !== "pending" &&
(x as { status?: unknown }).status !== "accepted")
) {
continue;
}
const acceptedRaw = (x as { acceptedAt?: unknown }).acceptedAt;
const acceptedAt =
acceptedRaw === null
? null
: typeof acceptedRaw === "string"
? acceptedRaw
: null;
out.push({
id: (x as { id: string }).id,
email: (x as { email: string }).email,
invitedAt: (x as { invitedAt: string }).invitedAt,
acceptedAt,
status: (x as { status: "pending" | "accepted" }).status,
});
}
return out;
}
export async function fetchRuleStakeholders(
ruleId: string,
): Promise<RuleStakeholderListItem[] | null> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
{ credentials: "include" },
);
if (!res.ok) return null;
const data = await safeParseJsonResponse(res);
return parseStakeholdersPayload(data);
} catch {
return null;
}
}
export type RuleStakeholderMutationResult =
| { ok: true }
| { ok: false; error: string; status: number; retryAfterMs?: number };
function retryAfterFromResponse(
res: Response,
data: unknown,
): number | undefined {
if (res.status !== 429) return undefined;
if (data && typeof data === "object" && "details" in data) {
const d = (data as { details?: unknown }).details;
if (d && typeof d === "object" && "retryAfterMs" in d) {
const ms = (d as { retryAfterMs?: unknown }).retryAfterMs;
if (typeof ms === "number" && ms > 0) return ms;
}
}
const h = res.headers.get("retry-after");
if (h) {
const sec = Number.parseInt(h, 10);
if (!Number.isNaN(sec)) return sec * 1000;
}
return undefined;
}
export async function addRuleStakeholder(
ruleId: string,
email: string,
): Promise<RuleStakeholderMutationResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders`,
{
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ email }),
},
);
if (res.ok) return { ok: true };
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
retryAfterMs: retryAfterFromResponse(res, data),
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export async function deleteRuleStakeholder(
ruleId: string,
stakeholderId: string,
): Promise<RuleStakeholderMutationResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}`,
{ method: "DELETE", credentials: "include" },
);
if (res.ok) return { ok: true };
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export async function resendRuleStakeholderInvite(
ruleId: string,
stakeholderId: string,
): Promise<RuleStakeholderMutationResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(ruleId)}/stakeholders/${encodeURIComponent(stakeholderId)}/resend`,
{ method: "POST", credentials: "include" },
);
if (res.ok) return { ok: true };
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
retryAfterMs: retryAfterFromResponse(res, data),
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DeleteRuleResult =
| { ok: true }
| { ok: false; error: string; status: number };
export async function deletePublishedRule(
id: string,
): Promise<DeleteRuleResult> {
try {
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) {
return { ok: true as const };
}
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DuplicateRuleResult =
| { ok: true; id: string; title: string }
| { ok: false; error: string; status: number };
export async function duplicatePublishedRule(
id: string,
): Promise<DuplicateRuleResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(id)}/duplicate`,
{
method: "POST",
credentials: "include",
},
);
const data = (await safeParseJsonResponse(res)) as {
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export async function duplicateUseCaseTemplate(
slug: string,
): Promise<DuplicateRuleResult> {
try {
const res = await fetch(
`/api/use-cases/${encodeURIComponent(slug)}/duplicate`,
{
method: "POST",
credentials: "include",
},
);
const data = (await safeParseJsonResponse(res)) as {
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DeleteAccountResult = { ok: true } | { ok: false; error: string };
/**
* Permanently deletes the signed-in user. Caller should redirect and refresh UI.
*/
export async function deleteAccount(): Promise<DeleteAccountResult> {
try {
const res = await fetch("/api/user/me", {
method: "DELETE",
credentials: "include",
});
if (res.ok) {
return { ok: true as const };
}
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
};
}
}
export type ServerDraftForProfile =
| { hasDraft: false }
| { hasDraft: true; updatedAt: string; state: CreateFlowState };
/**
* Fetches the signed-in users server draft for the profile page. Returns
* `null` on auth/transport failure.
*/
export async function fetchServerDraftForProfile(): Promise<
ServerDraftForProfile | null
> {
try {
const res = await fetch("/api/drafts/me", { credentials: "include" });
if (res.status === 401) return null;
if (!res.ok) return null;
const data = (await parseJson(res)) as {
draft: { payload: unknown; updatedAt: string } | null;
};
if (!data.draft) {
return { hasDraft: false };
}
const payload = data.draft.payload;
const state: CreateFlowState =
payload && typeof payload === "object"
? migrateLegacyCreateFlowState(
payload as Record<string, unknown>,
)
: {};
const rawUpdated = data.draft.updatedAt;
const updatedAt =
typeof rawUpdated === "string"
? rawUpdated
: new Date().toISOString();
return { hasDraft: true, updatedAt, state };
} catch {
return null;
}
}