/** * 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, }); // 204 No Content (archive, delete, reset…) is success with no body — // there's no JSON to parse. if (res.status === 204) return { data: undefined as T }; 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 Stats = { users: number; users_last_7_days: number; posts: number; posts_last_7_days: number; collections: number; public_collections: number; bookmarks: number; reshares: number; feedback: number; feedback_last_7_days: number; feedback_by_category: { category: string; count: number }[]; }; export type AdminShortUrl = { id: number; code: string; long_url: string; content_type: string; click_count: number; human_click_count: number; disabled: boolean; created_at: string; }; export type ShortUrlRangeStat = { created: number; clicks: number }; export type ShortUrlStats = { today: ShortUrlRangeStat; week: ShortUrlRangeStat; month: ShortUrlRangeStat; total_urls: number; total_clicks: number; range?: ShortUrlRangeStat; // present only when a custom from/to is requested }; export type AdminUser = { id: string; email: string | null; handle: string; name: string; avatar_url: string | null; role: string; /** Staff role key (superadmin/admin/moderator/custom), absent for users. */ admin_role?: string | null; plan: string; email_verified_at: string | null; banned_at: string | null; post_count: number; created_at: string; has_password: boolean; providers: string[]; /** Effective save limits in force (plan default merged with any override); * -1 means unlimited. */ bookmark_limit: number; public_collection_limit: number; private_collection_limit: number; /** Direct-disciple link: the Mongo disciple id this account is linked to, * and that disciple's name (hydrated server-side). Both null when the * account isn't one of Śrīla Prabhupāda's direct disciples. */ disciple_id: string | null; disciple_name: string | null; }; /** One option in the direct-disciple picker. `linked_handle` is the @handle of * the account already linked to this disciple — non-null means "taken". */ export type AdminDisciple = { id: string; initiated_name: string; suffix: string; initiation_date: string | null; initiation_place: string | null; linked_handle: string | null; }; /** Per-user save-limit override payload. null = reset that limit to the plan * default; a number caps it for this user. */ export type UserLimitsInput = { bookmark_limit: number | null; public_collection_limit: number | null; private_collection_limit: number | null; }; 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; /** The category's current display name. */ category_label?: string; message: string; contact_email: string | null; created_at: string; acknowledged_at: string | null; 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; resolution_note: string | null; target_removed: boolean; 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). The BFF returns the bare User on a completed // login (200), or { otp_required: true } (202) when an emailed code is // needed — there's no { kind } wrapper, so we derive it here. async login(email: string, password: string): Promise { const { data } = await call("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); if (data && (data as { otp_required?: boolean }).otp_required) { return { kind: "otp_required" }; } return { kind: "session", user: data as Me }; }, async verifyLoginOtp( email: string, password: string, otp: string, ): Promise { const { data } = await call("/auth/verify-login-otp", { method: "POST", body: JSON.stringify({ email, password, otp }), }); return data; }, 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 async stats(): Promise { const { data } = await call("/admin/stats"); return data; }, 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" }); }, setUserLimits(id: string, limits: UserLimitsInput) { return call(`/admin/users/${id}/limits`, { method: "POST", body: JSON.stringify(limits), }); }, /** Search Śrīla Prabhupāda's disciples for the link picker. An empty query * returns the first page alphabetically. */ async disciples(q: string, limit = 25): Promise { const { data } = await call( `/admin/disciples${qs({ q, limit })}`, ); return data ?? []; }, /** Mark a user as a direct disciple. Re-linking moves an existing link. */ linkDisciple(userId: string, discipleId: string) { return call(`/admin/users/${userId}/disciple`, { method: "POST", body: JSON.stringify({ disciple_id: discipleId }), }); }, unlinkDisciple(userId: string) { return call(`/admin/users/${userId}/disciple`, { 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 })}`); }, shortUrls(q: string, limit: number, offset: number) { return listed(`/admin/short-urls${qs({ q, limit, offset })}`); }, async shortUrlStats(from?: string, to?: string): Promise { const { data } = await call( `/admin/short-urls/stats${qs({ from, to })}`, ); return data; }, 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 }), }); }, acknowledgeFeedback(id: string) { return call(`/admin/feedback/${id}/acknowledge`, { method: "POST" }); }, resolveFeedback(id: string) { return call(`/admin/feedback/${id}/resolve`, { method: "POST" }); }, reports(type: ReportType, q: string, limit: number, offset: number) { return listed(`/admin/reports${qs({ type, q, limit, offset })}`); }, resolveReport(type: ReportType, id: string, note: string) { return call(`/admin/reports/${type}/${id}/resolve`, { method: "POST", body: JSON.stringify({ note }), }); }, }; // ─── Broadcasts ───────────────────────────────────────────────────────────── /** * Announcements pushed to every user and shown once, in a modal. * * No history is kept on the user's side: there is no notification row and no * tray entry, so a broadcast exists only until each person dismisses it. */ export type Broadcast = { id: string; title: string; body: string; banner_url?: string | null; cta_label?: string | null; cta_url?: string | null; published_at?: string | null; expires_at?: string | null; created_at: string; /** How many people have dismissed it — the only delivery signal there is. */ seen_count: number; }; export type NewBroadcast = { title: string; body: string; banner_key?: string | null; cta_label?: string | null; cta_url?: string | null; expires_at?: string | null; publish: boolean; }; export const broadcasts = { list: () => listed("/admin/broadcasts?limit=50"), create: (input: NewBroadcast) => call("/admin/broadcasts", { method: "POST", body: JSON.stringify(input), }).then((r) => r.data), publish: (id: string) => call(`/admin/broadcasts/${id}/publish`, { method: "POST" }).then( (r) => r.data, ), remove: (id: string) => call(`/admin/broadcasts/${id}`, { method: "DELETE" }), /** Presign a banner upload; returns the PUT URL and the key to submit. */ presignBanner: (contentType: string) => call<{ upload_url: string; key: string }>( "/admin/broadcasts/banner-presign", { method: "POST", body: JSON.stringify({ content_type: contentType }) }, ).then((r) => r.data), }; // ─── Reward gifts ─────────────────────────────────────────────────────────── /** A sticker from the sādhana catalog, sendable as an occasional gift. */ export type GiftableReward = { id: string; key: string; name: string; meaning: string; image_url: string; }; export const rewardGifts = { catalog: () => call("/admin/reward-gifts/catalog").then((r) => r.data), /** Presign artwork for a custom gift. Returns the final public URL too, * since sadhna_rewards.image_url stores an absolute address. */ presignImage: (contentType: string) => call<{ upload_url: string; image_url: string }>( "/admin/reward-gifts/image-presign", { method: "POST", body: JSON.stringify({ content_type: contentType }) }, ).then((r) => r.data), /** Delete a custom sticker. Cascades out of every gallery and vault. */ remove: (key: string) => call(`/admin/reward-gifts/${encodeURIComponent(key)}`, { method: "DELETE", }), /** Add a sticker to the catalog. It reaches nobody until it is sent. */ create: (input: { name: string; meaning: string; image_url: string }) => call("/admin/reward-gifts/catalog", { method: "POST", body: JSON.stringify(input), }).then((r) => r.data), /** Hand a reward to every account, unopened. Idempotent — a repeat only * reaches people who don't already have it. */ send: (rewardKey: string) => call<{ granted: number }>("/admin/reward-gifts", { method: "POST", body: JSON.stringify({ reward_key: rewardKey }), }).then((r) => r.data), }; // ─── Vāṇī of the Day ──────────────────────────────────────────────────────── /** One decided day. `payload` is the blob the home-page CTA renders. */ export type VaniPick = { day: string; kind: "verse" | "prose" | "transcript"; ref_id: string; payload: { kind: string; label: string; title: string; subtitle: string; href: string; book_title?: string; date?: string; }; }; /** A search hit, narrowed to what pinning needs. */ export type VaniCandidate = { collection: string; doc_id: string; title: string; snippet: string; url: string; }; /** Typesense collection → the kind the vani picker uses. */ const VANI_KINDS: Record = { verses: "verse", prose: "prose", transcriptions: "transcript", }; export function vaniKindFor(collection: string): VaniPick["kind"] | null { return VANI_KINDS[collection] ?? null; } export const vani = { /** Decided days from today onward. */ schedule: () => call("/admin/vani?limit=30").then((r) => r.data), /** Pin one item to a day. Only the reference travels — the BFF rebuilds the * display blob, so a pinned pick renders exactly like an automatic one. */ set: (input: { day: string; kind: VaniPick["kind"]; ref_id: string }) => call("/admin/vani", { method: "POST", body: JSON.stringify(input), }).then((r) => r.data), /** Hand the day back to the random picker. */ clear: (day: string) => call(`/admin/vani/${encodeURIComponent(day)}`, { method: "DELETE" }), /** Pin whatever an app link points at. The only reliable way to choose a * specific item: slugs are not indexed, so searching one matches on its * words instead ("720720sb-london" → anything mentioning London). */ setByURL: (input: { day: string; url: string }) => call("/admin/vani/by-url", { method: "POST", body: JSON.stringify(input), }).then((r) => r.data), /** * Search the vāṇī-eligible collections. * * SCOPED server-side via `type`, not filtered here: `type=all` blends every * collection and ranks by relevance, so the top hits are dominated by * letters and glossary entries and a client-side filter throws the whole * page away — which is exactly what made this look broken. */ search: async (q: string): Promise => { const { data } = await call<{ results: VaniCandidate[] }>( `/search?type=verses,prose,transcriptions&limit=20&q=${encodeURIComponent(q)}`, ); return (data?.results ?? []).filter( (r) => r.doc_id && vaniKindFor(r.collection), ); }, }; // ─── Staff, roles and permissions ─────────────────────────────────────────── /** The signed-in staff member's role and effective permissions. */ export type StaffMe = { role_key: string; role_name: string; permissions: string[]; }; /** Held only by superadmins: managing other staff and roles. */ export const MANAGE_STAFF = "admins:manage"; export type Permission = { key: string; group: string; label: string }; export type AdminRole = { id: string; key: string; name: string; description: string; is_system: boolean; permissions: string[]; member_count: number; }; export type StaffMember = { user_id: string; handle: string; name: string; email?: string | null; avatar_url?: string | null; role_id: string; role_key: string; role_name: string; blocked_at?: string | null; }; export const staff = { /** Resolves only for staff; a non-staff or blocked account gets a 403. */ me: () => call("/admin/me").then((r) => r.data), catalog: () => call("/admin/permissions").then((r) => r.data), list: () => call("/admin/staff").then((r) => r.data), grant: (handle: string, roleId: string) => call("/admin/staff", { method: "POST", body: JSON.stringify({ handle, role_id: roleId }), }).then((r) => r.data), changeRole: (userId: string, roleId: string) => call(`/admin/staff/${userId}`, { method: "PATCH", body: JSON.stringify({ role_id: roleId }), }).then((r) => r.data), revoke: (userId: string) => call(`/admin/staff/${userId}`, { method: "DELETE" }), setBlocked: (userId: string, blocked: boolean) => call(`/admin/staff/${userId}/block`, { method: blocked ? "POST" : "DELETE", }).then((r) => r.data), roles: () => call("/admin/roles").then((r) => r.data), createRole: (input: { name: string; description: string; permissions: string[] }) => call("/admin/roles", { method: "POST", body: JSON.stringify(input) }).then( (r) => r.data, ), updateRole: ( id: string, input: { name: string; description: string; permissions: string[] }, ) => call(`/admin/roles/${id}`, { method: "PATCH", body: JSON.stringify(input), }).then((r) => r.data), deleteRole: (id: string) => call(`/admin/roles/${id}`, { method: "DELETE" }), }; // ─── Quizzes ──────────────────────────────────────────────────────────────── export type QuizTargetKind = "chapter" | "lecture" | "conversation" | "standalone"; export type QuizOption = { id?: string; ord?: number; body: string; is_correct: boolean }; export type QuizQuestion = { id?: string; ord?: number; prompt: string; explanation: string; /** "Select all that apply": one or more options correct. */ multi: boolean; options: QuizOption[]; }; export type Quiz = { id: string; title: string; description: string; target_kind: QuizTargetKind; target_path?: string | null; target_title: string; published_at?: string | null; created_at: string; updated_at: string; question_count: number; attempt_count?: number; completed_count?: number; questions?: QuizQuestion[]; }; export type QuizDraft = { title: string; description: string; /** Pasted app link; empty for a stand-alone quiz. */ target_url: string; /** Optional override for the resolved content title. */ target_title: string; /** Omitted once a quiz has attempts — its questions are frozen. */ questions?: QuizQuestion[]; }; export type QuizParticipant = { user_id: string; handle: string; name: string; avatar_url?: string | null; started_at: string; completed_at?: string | null; answered: number; score: number; total: number; total_ms: number; }; export type QuizQuestionStat = { id: string; ord: number; prompt: string; multi: boolean; answered: number; correct: number; avg_ms: number; median_ms: number; options: (QuizOption & { id: string; picks: number })[]; }; export type QuizResultItem = QuizQuestion & { chosen_option_ids: string[]; is_correct: boolean; response_ms?: number | null; }; export type QuizResult = { quiz: Quiz; score: number; total: number; started_at: string; completed_at?: string | null; total_ms: number; items: QuizResultItem[]; }; export const quizzes = { list: () => call("/admin/quizzes").then((r) => r.data), get: (id: string) => call(`/admin/quizzes/${id}`).then((r) => r.data), create: (draft: QuizDraft) => call("/admin/quizzes", { method: "POST", body: JSON.stringify(draft) }).then( (r) => r.data, ), update: (id: string, draft: QuizDraft) => call(`/admin/quizzes/${id}`, { method: "PUT", body: JSON.stringify(draft) }).then( (r) => r.data, ), setPublished: (id: string, on: boolean) => call(`/admin/quizzes/${id}/publish`, { method: on ? "POST" : "DELETE" }).then( (r) => r.data, ), remove: (id: string) => call(`/admin/quizzes/${id}`, { method: "DELETE" }), participants: (id: string) => call(`/admin/quizzes/${id}/participants`).then((r) => r.data), participant: (id: string, userId: string) => call(`/admin/quizzes/${id}/participants/${userId}`).then((r) => r.data), responses: (id: string) => call(`/admin/quizzes/${id}/responses`).then((r) => r.data), /** Deletes one person's attempt; the quiz is pending for them again. */ resetParticipant: (id: string, userId: string) => call(`/admin/quizzes/${id}/participants/${userId}`, { method: "DELETE" }), }; // ─── Feedback categories ──────────────────────────────────────────────────── /** One option on the app's /feedback form. `key` is fixed once created. */ export type FeedbackCategory = { key: string; label: string; description: string; sort_order: number; archived_at?: string | null; /** How much feedback is filed under it. */ usage_count?: number; }; export const feedbackCategories = { list: () => call("/admin/feedback/categories").then((r) => r.data), create: (label: string, description: string) => call("/admin/feedback/categories", { method: "POST", body: JSON.stringify({ label, description }), }).then((r) => r.data), update: (key: string, v: { label: string; description: string; archived: boolean }) => call(`/admin/feedback/categories/${encodeURIComponent(key)}`, { method: "PUT", body: JSON.stringify(v), }), remove: (key: string) => call(`/admin/feedback/categories/${encodeURIComponent(key)}`, { method: "DELETE", }), reorder: (keys: string[]) => call("/admin/feedback/categories/order", { method: "PUT", body: JSON.stringify({ keys }), }), };