import type { CreateFlowState } from "../../app/(app)/create/types"; import { migrateLegacyCreateFlowState } from "./migrateLegacyCreateFlowState"; const jsonHeaders = { "Content-Type": "application/json" }; async function parseJson(response: Response): Promise { const data = (await response.json()) as T; return data; } /** Reads `message` from API error JSON (canonical `{ error: { code, message } }` or legacy `{ error: string }`). */ function readApiErrorMessage(data: unknown): string { if (data && typeof data === "object" && "error" in data) { const err = (data as { error: unknown }).error; if (typeof err === "string") { return err; } if ( err && typeof err === "object" && "message" in err && typeof (err as { message: unknown }).message === "string" ) { return (err as { message: string }).message; } } 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, draft?: CreateFlowState, ): 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 } : {}), ...(draft && Object.keys(draft).length > 0 ? { draft } : {}), }), }); 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 { 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 { 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, ); } 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 { 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 { 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 { 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 { 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; 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; }, ): 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 user’s 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 user’s 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, ) : {}; const rawUpdated = data.draft.updatedAt; const updatedAt = typeof rawUpdated === "string" ? rawUpdated : new Date().toISOString(); return { hasDraft: true, updatedAt, state }; } catch { return null; } }