Files
chat_room/src/lib/api.ts
T

458 lines
11 KiB
TypeScript
Raw Normal View History

import { tokenStore } from "./token-store";
export const API_BASE =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
// آدرس ریشه‌ی بک‌اند (بدون /api/v1) — برای ساختن URL کامل فایل‌های استاتیک (عکس‌ها)
export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
// url ذخیره‌شده توی دیتابیس یه مسیر نسبیه (مثلاً /uploads/photos/3/xxx.jpg)
export function photoUrl(path: string): string {
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 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;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
}
export interface AuthResponseData {
tokens: TokenPair;
user: UserProfile;
is_new_user: boolean;
onboarding_done: boolean;
}
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 Option {
id: number;
category_id: number;
title: string;
sort_order: number;
is_active: boolean;
}
export interface ProfileQuestion {
id: number;
text: string;
sort_order: number;
is_active: boolean;
}
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 Photo {
id: number;
user_id: number;
url: string;
sort_order: number;
}
export interface FullProfile {
user: UserProfile;
onboarding_status: OnboardingStatus;
selections: SelectionSummary[];
profile_answers: ProfileAnswer[];
photos: Photo[];
}
// ---------------- دوستیابی ----------------
export interface DiscoverCandidate {
id: number;
first_name: string;
age: number;
photos: string[];
distance_km?: number;
interests?: string[];
}
export interface CandidateAnswer {
question: string;
answer: string;
}
export interface CandidateDetail {
id: number;
first_name: string;
age: number;
photos: string[];
distance_km?: number;
height_cm?: number;
weight_kg?: number;
education?: string;
field_of_study?: string;
occupations?: string[];
interests?: string[];
important_in_relationship?: string[];
looking_for?: string;
profile_answers?: CandidateAnswer[];
}
export type SwipeActionType = "like" | "pass" | "superlike";
export interface MatchView {
id: number;
user_id: number;
first_name: string;
photos: string[];
created_at: string;
last_message?: string;
last_at?: string;
}
export interface SwipeResult {
matched: boolean;
match?: MatchView;
}
export interface MessageView {
id: number;
match_id: number;
sender_id: number;
text: string;
created_at: string;
is_mine: boolean;
}
export type CategoryKey =
| "education_level"
| "field_of_study"
| "occupation"
| "important_in_relationship"
| "interest"
| "looking_for";
// ---------------- هسته‌ی 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 {
// بدنه خالی (مثلاً بعضی پاسخ‌های موفق بدون data)
}
if (!res.ok) {
throw new ApiError(
body?.message ?? "خطای غیرمنتظره از سرور",
res.status,
body?.errors
);
}
return (body?.data as T) ?? (undefined as T);
}
/**
* درخواست احراز هویت‌دار؛ اگه توکن access منقضی شده باشه (۴۰۱)،
* یک‌بار تلاش می‌کنه با refresh token توکن جدید بگیره و دوباره درخواست رو بفرسته.
*/
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;
}
}
/** مثل authRequest ولی برای آپلود فایل (بدون ست‌کردن Content-Type، چون FormData خودش boundary می‌سازه) */
async function authUpload<T>(path: string, formData: FormData): Promise<T> {
async function send(): Promise<T> {
const headers = new Headers();
const token = tokenStore.getAccess();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, 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);
}
try {
return await send();
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
const refreshed = await ensureRefreshed();
if (refreshed) return await send();
}
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<FullProfile>("/profile/me");
}
// ---------------- آنبوردینگ ----------------
export function getOnboardingStatus() {
return authRequest<OnboardingStatus>("/onboarding/status");
}
export function getOptions(category: CategoryKey) {
return authRequest<Option[]>(`/onboarding/options/${category}`);
}
export function getAvailableQuestions() {
return authRequest<ProfileQuestion[]>("/onboarding/profile-questions");
}
export function submitBasicInfo(payload: {
gender: "male" | "female";
first_name: string;
birth_date: string;
}) {
return authRequest<OnboardingStatus>("/onboarding/basic-info", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function submitBodyInfo(payload: { height_cm: number; weight_kg: number }) {
return authRequest<OnboardingStatus>("/onboarding/body-info", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function submitProfileQuestions(
answers: { question_id: number; answer: string }[]
) {
return authRequest<OnboardingStatus>("/onboarding/profile-questions", {
method: "POST",
body: JSON.stringify({ answers }),
});
}
export function submitEducation(payload: {
education_level_option_id: number;
field_of_study_option_id: number;
}) {
return authRequest<OnboardingStatus>("/onboarding/education", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function submitOccupations(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/occupations", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
export function submitRelationshipImportance(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/relationship-importance", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
export function submitInterests(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/interests", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
export function submitLookingFor(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/looking-for", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
// ---------------- عکس‌ها و موقعیت مکانی ----------------
export function uploadPhoto(file: File) {
const formData = new FormData();
formData.append("photo", file);
return authUpload<Photo>("/profile/photos", formData);
}
export function deletePhoto(id: number) {
return authRequest<null>(`/profile/photos/${id}`, { method: "DELETE" });
}
export function updateLocation(latitude: number, longitude: number) {
return authRequest<null>("/profile/location", {
method: "PATCH",
body: JSON.stringify({ latitude, longitude }),
});
}
// ---------------- دوستیابی ----------------
export function getDiscoverFeed(params?: {
maxDistanceKm?: number;
minAge?: number;
maxAge?: number;
}) {
const query = new URLSearchParams();
if (params?.maxDistanceKm) query.set("max_distance_km", String(params.maxDistanceKm));
if (params?.minAge) query.set("min_age", String(params.minAge));
if (params?.maxAge) query.set("max_age", String(params.maxAge));
const qs = query.toString();
return authRequest<DiscoverCandidate[]>(`/discover${qs ? `?${qs}` : ""}`);
}
export function getCandidateDetail(id: number) {
return authRequest<CandidateDetail>(`/discover/${id}`);
}
export function swipe(targetId: number, action: SwipeActionType) {
return authRequest<SwipeResult>(`/discover/${targetId}/swipe`, {
method: "POST",
body: JSON.stringify({ action }),
});
}
export function listMatches() {
return authRequest<MatchView[]>("/matches");
}
export function listMessages(matchId: number) {
return authRequest<MessageView[]>(`/matches/${matchId}/messages`);
}
export function sendMessage(matchId: number, text: string) {
return authRequest<MessageView>(`/matches/${matchId}/messages`, {
method: "POST",
body: JSON.stringify({ text }),
});
}