The api client now calls normalizePath() to strip accidental /api/ prefixes. This prevents the recurring /api/api/ double-prefix bug. Added convention note to .claude/CLAUDE.md so future workers know.
130 lines
3.1 KiB
TypeScript
130 lines
3.1 KiB
TypeScript
import { createClient } from "@/lib/supabase/client";
|
|
import type { ApiError } from "@/lib/types";
|
|
|
|
class ApiClient {
|
|
private baseUrl = "/api";
|
|
|
|
/** Strip leading /api/ if accidentally included — baseUrl already provides it */
|
|
private normalizePath(path: string): string {
|
|
if (path.startsWith("/api/")) {
|
|
return path.slice(4); // "/api/foo" -> "/foo"
|
|
}
|
|
return path;
|
|
}
|
|
|
|
private async getHeaders(): Promise<HeadersInit> {
|
|
const supabase = createClient();
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession();
|
|
|
|
const headers: HeadersInit = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
if (session?.access_token) {
|
|
headers["Authorization"] = `Bearer ${session.access_token}`;
|
|
}
|
|
|
|
const tenantId = typeof window !== "undefined"
|
|
? localStorage.getItem("kanzlai_tenant_id")
|
|
: null;
|
|
if (tenantId) {
|
|
headers["X-Tenant-ID"] = tenantId;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
private async request<T>(
|
|
rawPath: string,
|
|
options: RequestInit = {},
|
|
): Promise<T> {
|
|
const path = this.normalizePath(rawPath);
|
|
const headers = await this.getHeaders();
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
...options,
|
|
headers: { ...headers, ...options.headers },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
const err: ApiError = {
|
|
error: body.error || res.statusText,
|
|
status: res.status,
|
|
};
|
|
throw err;
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json();
|
|
}
|
|
|
|
get<T>(path: string) {
|
|
return this.request<T>(path, { method: "GET" });
|
|
}
|
|
|
|
post<T>(path: string, body?: unknown) {
|
|
return this.request<T>(path, {
|
|
method: "POST",
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
put<T>(path: string, body?: unknown) {
|
|
return this.request<T>(path, {
|
|
method: "PUT",
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
patch<T>(path: string, body?: unknown) {
|
|
return this.request<T>(path, {
|
|
method: "PATCH",
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
delete<T>(path: string) {
|
|
return this.request<T>(path, { method: "DELETE" });
|
|
}
|
|
|
|
async postFormData<T>(rawPath: string, formData: FormData): Promise<T> {
|
|
const path = this.normalizePath(rawPath);
|
|
const supabase = createClient();
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession();
|
|
|
|
const headers: HeadersInit = {};
|
|
if (session?.access_token) {
|
|
headers["Authorization"] = `Bearer ${session.access_token}`;
|
|
}
|
|
const tenantId = typeof window !== "undefined"
|
|
? localStorage.getItem("kanzlai_tenant_id")
|
|
: null;
|
|
if (tenantId) {
|
|
headers["X-Tenant-ID"] = tenantId;
|
|
}
|
|
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
method: "POST",
|
|
headers,
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
const err: ApiError = {
|
|
error: body.error || res.statusText,
|
|
status: res.status,
|
|
};
|
|
throw err;
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
}
|
|
|
|
export const api = new ApiClient();
|