- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest
Первый запуск на сервере (вручную, один раз):
- создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
- docker network create gendesign_shared (если нет)
- docker compose -p gendesign-tradein up -d
- docker compose -p gendesign exec caddy caddy reload
92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
import { getOrCreateSessionId } from "@/lib/sessionId";
|
||
|
||
// Empty default = same-origin relative URLs.
|
||
// In prod Caddy proxies /api/* to backend; in dev Next.js rewrites do the same.
|
||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||
|
||
/**
|
||
* Merges a global X-Session-Id header into the request init.
|
||
* Only runs in browser (SSR guard). User-supplied headers win over the
|
||
* auto-injected value so explicit overrides remain backward-compatible.
|
||
*/
|
||
function withSessionHeader(init?: RequestInit): RequestInit {
|
||
const base: RequestInit = init ?? {};
|
||
if (typeof window === "undefined") return base;
|
||
const sessionId = getOrCreateSessionId();
|
||
if (!sessionId) return base;
|
||
return {
|
||
...base,
|
||
headers: {
|
||
"X-Session-Id": sessionId,
|
||
...(base.headers ?? {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function apiFetch<T>(
|
||
path: string,
|
||
init?: RequestInit,
|
||
): Promise<T> {
|
||
const merged = withSessionHeader(init);
|
||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||
...merged,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(merged.headers ?? {}),
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
||
}
|
||
return response.json() as Promise<T>;
|
||
}
|
||
|
||
/**
|
||
* Status-aware variant of apiFetch — возвращает status + body для случаев
|
||
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
|
||
*
|
||
* Throws на 4xx/5xx (используя HTTPError ниже); 2xx коды все ОК.
|
||
*/
|
||
export class HTTPError extends Error {
|
||
status: number;
|
||
body: unknown;
|
||
constructor(status: number, body: unknown, msg?: string) {
|
||
super(msg ?? `API error ${status}`);
|
||
this.status = status;
|
||
this.body = body;
|
||
}
|
||
}
|
||
|
||
export async function apiFetchWithStatus<T>(
|
||
path: string,
|
||
init?: RequestInit,
|
||
): Promise<{ status: number; body: T }> {
|
||
const merged = withSessionHeader(init);
|
||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||
...merged,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(merged.headers ?? {}),
|
||
},
|
||
});
|
||
let body: unknown = null;
|
||
// 204 No Content имеет пустое тело; для всех остальных читаем как text один раз
|
||
// (fetch body stream consumable только один раз — нельзя сначала .json() потом .text()).
|
||
if (response.status !== 204) {
|
||
const rawText = await response.text();
|
||
if (rawText) {
|
||
try {
|
||
body = JSON.parse(rawText);
|
||
} catch {
|
||
// Не JSON (HTML error page от Caddy / 502 / etc) — оборачиваем в detail
|
||
body = { detail: rawText };
|
||
}
|
||
}
|
||
}
|
||
if (!response.ok) {
|
||
const detail =
|
||
(body as { detail?: string })?.detail ?? `API error ${response.status}`;
|
||
throw new HTTPError(response.status, body, detail);
|
||
}
|
||
return { status: response.status, body: body as T };
|
||
}
|