chore: merge admin repo history into monorepo at admin/

git-subtree-dir: admin
git-subtree-mainline: 35f5084005
git-subtree-split: 9f1eccd2f7
This commit is contained in:
2026-09-27 10:30:47 +03:30
49 changed files with 11446 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
import { tokenStore } from "./token-store";
export const API_BASE =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
// عکس‌ها از مسیر نسبی (/uploads/...) برمی‌گردن، نه URL کامل؛ باید به origin
// سرور (بدون /api/v1) وصل بشن.
export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
export function photoUrl(path: string) {
if (!path) return "";
if (path.startsWith("http")) return path;
return `${API_ORIGIN}${path}`;
}
interface ApiEnvelope<T> {
success: boolean;
message?: string;
data?: T;
errors?: unknown;
}
export class ApiError extends Error {
errors?: unknown;
status: number;
constructor(message: string, status: number, errors?: unknown) {
super(message);
this.status = status;
this.errors = errors;
}
}
// ---------------- انواع ----------------
export type ApprovalStatus = "pending" | "approved" | "rejected";
export type UserState = "inactive" | "active" | "blocked";
export interface UserProfile {
id: number;
phone: string;
first_name?: string;
gender?: "male" | "female";
birth_date?: string;
height_cm?: number;
weight_kg?: number;
current_step: number;
is_onboarded: boolean;
approval_status: ApprovalStatus;
rejection_reason?: string;
is_admin: boolean;
state: UserState;
blocked_reason?: string;
blocked_at?: string;
}
export interface StepView {
step: number;
key: string;
done: boolean;
}
export interface OnboardingStatus {
current_step: number;
current_step_key: string;
is_onboarded: boolean;
approval_status: ApprovalStatus;
rejection_reason?: string;
steps: StepView[];
}
export interface SelectionSummary {
category_key: string;
option_ids: number[];
titles: string[];
}
export interface ProfileAnswer {
id: number;
user_id: number;
question_id: number;
answer: string;
question?: ProfileQuestion;
}
export interface UserPhoto {
id: number;
user_id: number;
url: string;
sort_order: number;
}
export interface FullProfile {
user: UserProfile;
onboarding_status: OnboardingStatus;
selections: SelectionSummary[];
profile_answers: ProfileAnswer[];
photos: UserPhoto[];
}
export interface TokenPair {
access_token: string;
refresh_token: string;
}
export interface AuthResponseData {
tokens: TokenPair;
user: UserProfile;
is_new_user: boolean;
onboarding_done: boolean;
}
export type CategoryKey =
| "education_level"
| "field_of_study"
| "occupation"
| "important_in_relationship"
| "interest"
| "looking_for";
export const ALL_CATEGORIES: { key: CategoryKey; fallbackTitle: string; emoji: string }[] = [
{ key: "education_level", fallbackTitle: "سطح تحصیلات", emoji: "🎓" },
{ key: "field_of_study", fallbackTitle: "رشته تحصیلی", emoji: "📚" },
{ key: "occupation", fallbackTitle: "فعالیت‌ها", emoji: "💼" },
{ key: "important_in_relationship", fallbackTitle: "اهمیت در رابطه", emoji: "❤️" },
{ key: "interest", fallbackTitle: "علاقه‌مندی‌ها", emoji: "✨" },
{ key: "looking_for", fallbackTitle: "دنبال چی می‌گردی", emoji: "🔍" },
];
export interface Category {
id: number;
key: CategoryKey;
title: string;
min_select: number;
max_select: number;
created_at: string;
updated_at: string;
}
export interface Option {
id: number;
category_id: number;
title: string;
sort_order: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface ProfileQuestion {
id: number;
text: string;
sort_order: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
// ---------------- هسته‌ی fetch ----------------
let refreshPromise: Promise<boolean> | null = null;
async function rawRequest<T>(
path: string,
options: RequestInit = {},
auth = false
): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
if (auth) {
const token = tokenStore.getAccess();
if (token) headers.set("Authorization", `Bearer ${token}`);
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
let body: ApiEnvelope<T> | null = null;
try {
body = await res.json();
} catch {
// بدنه‌ی خالی
}
if (!res.ok) {
throw new ApiError(body?.message ?? "خطای غیرمنتظره از سرور", res.status, body?.errors);
}
return (body?.data as T) ?? (undefined as T);
}
async function authRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
try {
return await rawRequest<T>(path, options, true);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
const refreshed = await ensureRefreshed();
if (refreshed) return await rawRequest<T>(path, options, true);
}
throw err;
}
}
async function ensureRefreshed(): Promise<boolean> {
const refresh = tokenStore.getRefresh();
if (!refresh) return false;
if (!refreshPromise) {
refreshPromise = rawRequest<TokenPair>("/auth/token/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: refresh }),
})
.then((tokens) => {
tokenStore.set(tokens.access_token, tokens.refresh_token);
return true;
})
.catch(() => {
tokenStore.clear();
return false;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
// ---------------- احراز هویت ----------------
export function requestOtp(phone: string) {
return rawRequest<null>("/auth/request-otp", {
method: "POST",
body: JSON.stringify({ phone }),
});
}
export function verifyOtp(phone: string, otp: string) {
return rawRequest<AuthResponseData>("/auth/verify-otp", {
method: "POST",
body: JSON.stringify({ phone, otp }),
});
}
export function getProfileMe() {
return authRequest<{ user: UserProfile }>("/profile/me");
}
// ---------------- ادمین: دسته‌ها و گزینه‌ها ----------------
export function getCategory(key: CategoryKey) {
return authRequest<Category>(`/admin/categories/${key}`);
}
export function updateCategory(
key: CategoryKey,
payload: { title?: string; min_select?: number; max_select?: number }
) {
return authRequest<Category>(`/admin/categories/${key}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function listOptions(key: CategoryKey) {
return authRequest<Option[]>(`/admin/categories/${key}/options`);
}
export function createOption(key: CategoryKey, payload: { title: string; sort_order?: number }) {
return authRequest<Option>(`/admin/categories/${key}/options`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateOption(
id: number,
payload: { title?: string; sort_order?: number; is_active?: boolean }
) {
return authRequest<Option>(`/admin/options/${id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function deleteOption(id: number) {
return authRequest<null>(`/admin/options/${id}`, { method: "DELETE" });
}
// ---------------- ادمین: سوالات پروفایل ----------------
export function listQuestions() {
return authRequest<ProfileQuestion[]>("/admin/profile-questions");
}
export function createQuestion(payload: { text: string; sort_order?: number }) {
return authRequest<ProfileQuestion>("/admin/profile-questions", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateQuestion(
id: number,
payload: { text?: string; sort_order?: number; is_active?: boolean }
) {
return authRequest<ProfileQuestion>(`/admin/profile-questions/${id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function deleteQuestion(id: number) {
return authRequest<null>(`/admin/profile-questions/${id}`, { method: "DELETE" });
}
// ---------------- ادمین: بررسی و تایید کاربران ----------------
export interface PendingUserView extends UserProfile {
photos: string[];
}
export function listPendingUsers() {
return authRequest<PendingUserView[]>("/admin/users/pending");
}
export function getUserDetail(id: number) {
return authRequest<FullProfile>(`/admin/users/${id}`);
}
export function approveUser(id: number) {
return authRequest<UserProfile>(`/admin/users/${id}/approve`, { method: "POST" });
}
export function rejectUser(id: number, reason: string) {
return authRequest<UserProfile>(`/admin/users/${id}/reject`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
// ---------------- ادمین: مانیتورینگ ----------------
export interface MonitoringSummary {
total_requests: number;
total_errors: number;
error_rate_pct: number;
avg_latency_ms: number;
p95_latency_ms: number;
requests_last_min: number;
started_at: string;
uptime_seconds: number;
}
export interface RequestLogEntry {
time: string;
method: string;
path: string;
status_code: number;
duration_ms: number;
client_ip: string;
user_id?: number;
}
export function getMonitoringSummary() {
return authRequest<MonitoringSummary>("/admin/monitoring/summary");
}
export function getRecentRequests(limit = 100, userId?: number) {
const userParam = userId ? `&user_id=${userId}` : "";
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}${userParam}`);
}
// ---------------- ادمین: همه‌ی کاربران، بلاک/آنبلاک، گزارش ----------------
export interface AdminUserListItem extends UserProfile {
report_count: number;
}
interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
page_size: number;
}
export function listAllUsers(search: string, page = 1, pageSize = 25) {
const searchParam = search ? `&search=${encodeURIComponent(search)}` : "";
return authRequest<PaginatedResult<AdminUserListItem>>(
`/admin/users?page=${page}&page_size=${pageSize}${searchParam}`
);
}
export function blockUser(id: number, reason: string) {
return authRequest<UserProfile>(`/admin/users/${id}/block`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
export function unblockUser(id: number) {
return authRequest<UserProfile>(`/admin/users/${id}/unblock`, { method: "POST" });
}
export interface Report {
id: number;
user_id: number;
reason: string;
created_at: string;
}
export function reportUser(id: number, reason: string) {
return authRequest<Report>(`/admin/users/${id}/report`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
export function listUserReports(id: number) {
return authRequest<Report[]>(`/admin/users/${id}/reports`);
}
// ---------------- ادمین: مچ‌ها (رفقا) ----------------
export interface AdminMatchView {
id: number;
created_at: string;
user_one_id: number;
user_one_name: string;
user_one_phone: string;
user_two_id: number;
user_two_name: string;
user_two_phone: string;
}
export function listAllMatches(page = 1, pageSize = 25) {
return authRequest<PaginatedResult<AdminMatchView>>(
`/admin/matches?page=${page}&page_size=${pageSize}`
);
}
// ---------------- ادمین: نظارت بر عکس‌ها ----------------
export interface AdminPhotoView {
id: number;
user_id: number;
url: string;
sort_order: number;
created_at: string;
owner_name: string;
owner_phone: string;
}
export function listAllPhotos(page = 1, pageSize = 50) {
return authRequest<PaginatedResult<AdminPhotoView>>(
`/admin/photos?page=${page}&page_size=${pageSize}`
);
}
export function deletePhotoAdmin(id: number) {
return authRequest<null>(`/admin/photos/${id}`, { method: "DELETE" });
}
export function reportPhoto(id: number, reason: string) {
return authRequest<Report>(`/admin/photos/${id}/report`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
// ---------------- ادمین: بک‌آپ دیتابیس ----------------
export interface BackupInfo {
filename: string;
size_bytes: number;
created_at: string;
}
export function listBackups() {
return authRequest<BackupInfo[]>("/admin/backups");
}
export function createBackup() {
return authRequest<BackupInfo>("/admin/backups", { method: "POST" });
}
// دانلود بک‌آپ نیاز به هدر Authorization داره (اندپوینت فقط-ادمینه)، پس نمی‌شه
// مستقیم از یک <a href> استفاده کرد؛ فایل رو با fetch احرازهویت‌شده می‌گیریم و
// خودمون دانلودش رو با یک لینک موقت شبیه‌سازی می‌کنیم.
export async function downloadBackup(filename: string) {
const token = tokenStore.getAccess();
const res = await fetch(`${API_BASE}/admin/backups/${encodeURIComponent(filename)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new ApiError("دانلود بک‌آپ ناموفق بود", res.status);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
ReactNode,
} from "react";
import { tokenStore } from "./token-store";
import { UserProfile, verifyOtp as apiVerifyOtp, getProfileMe, ApiError } from "./api";
type AuthStatus = "loading" | "guest" | "not-admin" | "authenticated";
interface AuthContextValue {
status: AuthStatus;
user: UserProfile | null;
login: (phone: string, otp: string) => Promise<UserProfile>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>("loading");
const [user, setUser] = useState<UserProfile | null>(null);
const bootstrap = useCallback(async () => {
const access = tokenStore.getAccess();
if (!access) {
setStatus("guest");
return;
}
try {
const profile = await getProfileMe();
if (!profile.user.is_admin) {
setUser(profile.user);
setStatus("not-admin");
return;
}
setUser(profile.user);
setStatus("authenticated");
} catch {
tokenStore.clear();
setStatus("guest");
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
bootstrap();
}, [bootstrap]);
const login = useCallback(async (phone: string, otp: string) => {
const data = await apiVerifyOtp(phone, otp);
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
if (!data.user.is_admin) {
setUser(data.user);
setStatus("not-admin");
// توکن رو نگه می‌داریم نه، پاک می‌کنیم چون این پنل فقط برای ادمین‌هاست
tokenStore.clear();
throw new ApiError("این حساب دسترسی ادمین نداره", 403);
}
setUser(data.user);
setStatus("authenticated");
return data.user;
}, []);
const logout = useCallback(() => {
tokenStore.clear();
setUser(null);
setStatus("guest");
}, []);
return (
<AuthContext.Provider value={{ status, user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth باید داخل AuthProvider استفاده بشه");
return ctx;
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "./auth-context";
import { ApiError } from "./api";
export function useRequireAdmin() {
const router = useRouter();
const { status, user } = useAuth();
useEffect(() => {
if (status === "guest") {
router.replace("/login");
}
}, [status, router]);
return { status, user };
}
export function formatApiError(err: unknown, fallback = "یه مشکلی پیش اومد؛ دوباره امتحان کن"): string {
if (err instanceof ApiError) {
if (typeof err.errors === "string" && err.errors && err.errors !== err.message) {
return `${err.message} — ${err.errors}`;
}
return err.message || fallback;
}
if (err instanceof Error) return err.message;
return fallback;
}
+27
View File
@@ -0,0 +1,27 @@
const ACCESS_KEY = "dating_app_access_token";
const REFRESH_KEY = "dating_app_refresh_token";
function isBrowser() {
return typeof window !== "undefined";
}
export const tokenStore = {
getAccess(): string | null {
if (!isBrowser()) return null;
return localStorage.getItem(ACCESS_KEY);
},
getRefresh(): string | null {
if (!isBrowser()) return null;
return localStorage.getItem(REFRESH_KEY);
},
set(access: string, refresh: string) {
if (!isBrowser()) return;
localStorage.setItem(ACCESS_KEY, access);
localStorage.setItem(REFRESH_KEY, refresh);
},
clear() {
if (!isBrowser()) return;
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
},
};
+16
View File
@@ -0,0 +1,16 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function calcAge(birthDate?: string): number | null {
if (!birthDate) return null;
const b = new Date(birthDate);
if (Number.isNaN(b.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - b.getFullYear();
const m = now.getMonth() - b.getMonth();
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
return age;
}