260 lines
7.3 KiB
TypeScript
260 lines
7.3 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 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<LoginResult> {
|
|
const { data } = await call<LoginResult>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
return data;
|
|
},
|
|
async verifyLoginOtp(
|
|
email: string,
|
|
password: string,
|
|
otp: string,
|
|
): Promise<Me> {
|
|
const { data } = await call<{ user: Me }>("/auth/verify-login-otp", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password, otp }),
|
|
});
|
|
return data.user;
|
|
},
|
|
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
|
|
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" });
|
|
},
|
|
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 })}`);
|
|
},
|
|
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 }),
|
|
});
|
|
},
|
|
reports(type: ReportType, q: string, limit: number, offset: number) {
|
|
return listed<AdminReport>(`/admin/reports${qs({ type, q, limit, offset })}`);
|
|
},
|
|
resolveReport(type: ReportType, id: string) {
|
|
return call(`/admin/reports/${type}/${id}/resolve`, { method: "POST" });
|
|
},
|
|
};
|