Files
community-rule/lib/create/api.ts
T
2026-04-25 17:57:58 -06:00

420 lines
11 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",
});
}
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>;
}): 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,
}),
});
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 type MyPublishedRule = {
id: string;
title: string;
summary: string | null;
createdAt: string;
updatedAt: string;
};
/**
* Lists the signed-in users published rules (newest first). 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;
return data.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 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 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;
}
}