477 lines
15 KiB
TypeScript
477 lines
15 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),
|
|
};
|
|
|
|
// ─── 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<GiftableReward[]>("/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),
|
|
|
|
/** Add a sticker to the catalog. It reaches nobody until it is sent. */
|
|
create: (input: { name: string; meaning: string; image_url: string }) =>
|
|
call<GiftableReward>("/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),
|
|
};
|