2026-09-15 23:26:24 +03:30
|
|
|
import { tokenStore } from "./token-store";
|
|
|
|
|
|
|
|
|
|
export const API_BASE =
|
|
|
|
|
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
|
|
|
|
|
|
2026-09-20 05:30:33 +03:30
|
|
|
// عکسها از مسیر نسبی (/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}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 23:26:24 +03:30
|
|
|
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";
|
|
|
|
|
|
2026-09-20 05:30:33 +03:30
|
|
|
export type UserState = "inactive" | "active" | "blocked";
|
|
|
|
|
|
2026-09-15 23:26:24 +03:30
|
|
|
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;
|
2026-09-20 05:30:33 +03:30
|
|
|
state: UserState;
|
|
|
|
|
blocked_reason?: string;
|
|
|
|
|
blocked_at?: string;
|
2026-09-15 23:26:24 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 FullProfile {
|
|
|
|
|
user: UserProfile;
|
|
|
|
|
onboarding_status: OnboardingStatus;
|
|
|
|
|
selections: SelectionSummary[];
|
|
|
|
|
profile_answers: ProfileAnswer[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 function listPendingUsers() {
|
|
|
|
|
return authRequest<UserProfile[]>("/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 }),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-09-19 22:17:44 +03:30
|
|
|
|
|
|
|
|
// ---------------- ادمین: مانیتورینگ ----------------
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 05:30:33 +03:30
|
|
|
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" });
|
2026-09-19 22:17:44 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------- ادمین: بکآپ دیتابیس ----------------
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|