/** * Admin API client. Talks to the same Go BFF as the main app, reusing its * cookie session: auth uses the shared /auth/* endpoints, and every admin * action lives under /admin/* (gated server-side by RequireAdmin). All * requests send credentials so the httpOnly session cookie travels. * * For this to work cross-origin, the BFF must list this app's origin in CORS * via the ADMIN_URL env var (see .env.example). */ const BFF = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; export class AdminError extends Error { constructor( public status: number, message: string, ) { super(message); this.name = "AdminError"; } } type Envelope = { success: boolean; data?: T; error?: { code: number; message: string }; meta?: { total?: number; page?: number; limit?: number; total_pages?: number }; }; async function call( path: string, init?: RequestInit, ): Promise<{ data: T; meta?: Envelope["meta"] }> { const res = await fetch(`${BFF}/api/v1${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, ...init, }); let body: Envelope; try { body = (await res.json()) as Envelope; } catch { throw new AdminError(res.status, res.statusText || "Request failed"); } if (!res.ok || body.success === false) { throw new AdminError( res.status, body.error?.message || res.statusText || "Request failed", ); } return { data: body.data as T, meta: body.meta }; } export type Paged = { items: T[]; total: number }; async function listed(path: string): Promise> { const { data, meta } = await call(path); const items = data ?? []; return { items, total: meta?.total ?? items.length }; } function qs(params: Record): string { const u = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== "") u.set(k, String(v)); } const s = u.toString(); return s ? `?${s}` : ""; } // ─── Types (mirror the BFF admin DTOs) ────────────────────────────────────── export type Me = { id: string; email: string | null; handle: string; name: string; role: string; }; export type AdminUser = { id: string; email: string | null; handle: string; name: string; avatar_url: string | null; role: string; plan: string; email_verified_at: string | null; banned_at: string | null; created_at: string; }; export type AdminPost = { id: string; user_id: string; caption: string; source_title: string; source_url: string; is_hidden: boolean; deleted_at: string | null; created_at: string; author_name: string; author_handle: string; likes_count: number; comments_count: number; }; export type AdminCollection = { id: string; user_id: string; title: string; description: string | null; is_public: boolean; item_count: number; created_at: string; owner_name: string; owner_handle: string; }; export type AdminFeedback = { id: string; category: string; message: string; contact_email: string | null; created_at: string; resolved_at: string | null; replied_at: string | null; reply_message: string | null; author_handle: string | null; author_name: string | null; author_email: string | null; }; export type ReportType = "post" | "comment" | "user"; export type AdminReport = { id: string; type: ReportType; category: string; reason: string; created_at: string; resolved_at: string | null; reporter_handle: string | null; reporter_name: string | null; target_id: string; target_text: string | null; target_author_handle: string | null; }; // Login can complete immediately (session) or require an emailed OTP (the // BFF's 30-day-stale gate). The admin login form handles both. export type LoginResult = | { kind: "session"; user: Me } | { kind: "otp_required" }; // ─── API surface ──────────────────────────────────────────────────────────── export const api = { // Auth (shared endpoints) async login(email: string, password: string): Promise { const { data } = await call("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); return data; }, async verifyLoginOtp( email: string, password: string, otp: string, ): Promise { const { data } = await call<{ user: Me }>("/auth/verify-login-otp", { method: "POST", body: JSON.stringify({ email, password, otp }), }); return data.user; }, async me(): Promise { try { const { data } = await call("/auth/me"); return data; } catch (e) { if (e instanceof AdminError && e.status === 401) return null; throw e; } }, async logout(): Promise { await call("/auth/logout", { method: "POST" }); }, async forgotPassword(email: string): Promise { await call("/auth/forgot-password", { method: "POST", body: JSON.stringify({ email }), }); }, async resetPassword(token: string, password: string): Promise { await call("/auth/reset-password", { method: "POST", body: JSON.stringify({ token, password }), }); }, // Admin resources users(q: string, limit: number, offset: number) { return listed(`/admin/users${qs({ q, limit, offset })}`); }, notifyUser(id: string, title: string, body: string) { return call(`/admin/users/${id}/notify`, { method: "POST", body: JSON.stringify({ title, body }), }); }, banUser(id: string) { return call(`/admin/users/${id}/ban`, { method: "POST" }); }, unbanUser(id: string) { return call(`/admin/users/${id}/ban`, { method: "DELETE" }); }, posts(q: string, limit: number, offset: number) { return listed(`/admin/posts${qs({ q, limit, offset })}`); }, hidePost(id: string) { return call(`/admin/posts/${id}/hide`, { method: "POST" }); }, unhidePost(id: string) { return call(`/admin/posts/${id}/hide`, { method: "DELETE" }); }, deletePost(id: string) { return call(`/admin/posts/${id}`, { method: "DELETE" }); }, restorePost(id: string) { return call(`/admin/posts/${id}/restore`, { method: "POST" }); }, deleteComment(id: string) { return call(`/admin/comments/${id}`, { method: "DELETE" }); }, restoreComment(id: string) { return call(`/admin/comments/${id}/restore`, { method: "POST" }); }, collections(q: string, limit: number, offset: number) { return listed(`/admin/collections${qs({ q, limit, offset })}`); }, feedback(status: string, limit: number, offset: number) { return listed(`/admin/feedback${qs({ status, limit, offset })}`); }, replyFeedback(id: string, message: string) { return call(`/admin/feedback/${id}/reply`, { method: "POST", body: JSON.stringify({ message }), }); }, reports(type: ReportType, q: string, limit: number, offset: number) { return listed(`/admin/reports${qs({ type, q, limit, offset })}`); }, resolveReport(type: ReportType, id: string) { return call(`/admin/reports/${type}/${id}/resolve`, { method: "POST" }); }, };