Files
spsays-admin/lib/api.ts
T
2026-08-29 18:34:33 +05:30

439 lines
13 KiB
TypeScript

/**
* 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<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
meta?: { total?: number; page?: number; limit?: number; total_pages?: number };
};
async function call<T>(
path: string,
init?: RequestInit,
): Promise<{ data: T; meta?: Envelope<T>["meta"] }> {
const res = await fetch(`${BFF}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
let body: Envelope<T>;
try {
body = (await res.json()) as Envelope<T>;
} 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<T> = { items: T[]; total: number };
async function listed<T>(path: string): Promise<Paged<T>> {
const { data, meta } = await call<T[]>(path);
const items = data ?? [];
return { items, total: meta?.total ?? items.length };
}
function qs(params: Record<string, string | number | undefined>): 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;
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;
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<LoginResult> {
const { data } = await call<Me | { otp_required?: boolean }>("/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<Me> {
const { data } = await call<Me>("/auth/verify-login-otp", {
method: "POST",
body: JSON.stringify({ email, password, otp }),
});
return data;
},
async me(): Promise<Me | null> {
try {
const { data } = await call<Me>("/auth/me");
return data;
} catch (e) {
if (e instanceof AdminError && e.status === 401) return null;
throw e;
}
},
async logout(): Promise<void> {
await call("/auth/logout", { method: "POST" });
},
async forgotPassword(email: string): Promise<void> {
await call("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
},
async resetPassword(token: string, password: string): Promise<void> {
await call("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
},
// Admin resources
async stats(): Promise<Stats> {
const { data } = await call<Stats>("/admin/stats");
return data;
},
users(q: string, limit: number, offset: number) {
return listed<AdminUser>(`/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<AdminDisciple[]> {
const { data } = await call<AdminDisciple[]>(
`/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<AdminPost>(`/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<AdminCollection>(`/admin/collections${qs({ q, limit, offset })}`);
},
shortUrls(q: string, limit: number, offset: number) {
return listed<AdminShortUrl>(`/admin/short-urls${qs({ q, limit, offset })}`);
},
async shortUrlStats(from?: string, to?: string): Promise<ShortUrlStats> {
const { data } = await call<ShortUrlStats>(
`/admin/short-urls/stats${qs({ from, to })}`,
);
return data;
},
feedback(status: string, limit: number, offset: number) {
return listed<AdminFeedback>(`/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<AdminReport>(`/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<Broadcast>("/admin/broadcasts?limit=50"),
create: (input: NewBroadcast) =>
call<Broadcast>("/admin/broadcasts", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.data),
publish: (id: string) =>
call<Broadcast>(`/admin/broadcasts/${id}/publish`, { method: "POST" }).then(
(r) => r.data,
),
remove: (id: string) =>
call<void>(`/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),
};