Complete family planning application with: - React frontend with TypeScript - Node.js/Express backend with TypeScript - Python ingestion service for document processing - Planning ingestion service with LLM integration - Shared UI components and type definitions - OAuth integration for calendar synchronization - Comprehensive documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
245 lines
9.0 KiB
TypeScript
245 lines
9.0 KiB
TypeScript
import type { CalendarConnectionStatus, CalendarProvider, ConnectedCalendar } from "../types/calendar";
|
|
import type { ChildProfile } from "@family-planner/types";
|
|
import type {
|
|
ScheduleUploadResponse,
|
|
IngestionStatusResponse,
|
|
IngestionConfigResponse,
|
|
DiagnoseUploadResponse,
|
|
RepairIngestionResponse,
|
|
ApiSuccessResponse
|
|
} from "../types/api";
|
|
|
|
export const API_BASE_URL =
|
|
import.meta.env.VITE_API_URL ?? "http://localhost:5000/api";
|
|
|
|
type HttpVerb = "GET" | "POST" | "PATCH" | "DELETE";
|
|
|
|
async function request<TResponse>(
|
|
path: string,
|
|
method: HttpVerb,
|
|
body?: unknown
|
|
): Promise<TResponse> {
|
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Cache-Control": "no-cache" // Force le backend à retourner 200 au lieu de 304
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API error ${response.status}`);
|
|
}
|
|
|
|
// Gérer 304 Not Modified (pas de body, utiliser le cache navigateur)
|
|
if (response.status === 304) {
|
|
throw new Error("Data not modified (304) - this should not happen with Cache-Control: no-cache");
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as TResponse;
|
|
}
|
|
|
|
const contentLength = response.headers.get("Content-Length");
|
|
if (contentLength === "0" || response.status === 202) {
|
|
return undefined as TResponse;
|
|
}
|
|
|
|
const contentType = response.headers.get("Content-Type") ?? "";
|
|
if (!contentType.includes("application/json")) {
|
|
return undefined as TResponse;
|
|
}
|
|
|
|
return (await response.json()) as TResponse;
|
|
}
|
|
|
|
export const apiClient = {
|
|
get: <T>(path: string) => request<T>(path, "GET"),
|
|
post: <T>(path: string, payload: unknown) => request<T>(path, "POST", payload),
|
|
patch: <T>(path: string, payload: unknown) => request<T>(path, "PATCH", payload),
|
|
delete: <T>(path: string) => request<T>(path, "DELETE")
|
|
};
|
|
|
|
export const uploadAvatar = async (
|
|
file: File
|
|
): Promise<{ url: string; filename: string }> => {
|
|
const formData = new FormData();
|
|
formData.append("avatar", file);
|
|
|
|
const response = await fetch(`${API_BASE_URL}/uploads/avatar`, {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Upload error ${response.status}`);
|
|
}
|
|
|
|
return (await response.json()) as { url: string; filename: string };
|
|
};
|
|
|
|
export const uploadPlanning = async (
|
|
childId: string,
|
|
file: File
|
|
): Promise<ScheduleUploadResponse> => {
|
|
const form = new FormData();
|
|
form.append("childId", childId);
|
|
form.append("planning", file);
|
|
const response = await fetch(`${API_BASE_URL}/uploads/planning`, {
|
|
method: "POST",
|
|
body: form
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Upload planning error ${response.status}`);
|
|
}
|
|
return (await response.json()) as ScheduleUploadResponse;
|
|
};
|
|
|
|
export const listAvatars = async (): Promise<Array<{ filename: string; url: string }>> => {
|
|
const response = await fetch(`${API_BASE_URL}/uploads/avatars`);
|
|
if (!response.ok) {
|
|
throw new Error(`Gallery error ${response.status}`);
|
|
}
|
|
return (await response.json()) as Array<{ filename: string; url: string }>;
|
|
};
|
|
|
|
// Ingestion helper endpoints (proxied via backend)
|
|
export const getIngestionStatus = async (): Promise<IngestionStatusResponse> => {
|
|
const response = await fetch(`${API_BASE_URL}/ingestion/status`);
|
|
return (await response.json()) as IngestionStatusResponse;
|
|
};
|
|
|
|
export const getIngestionConfig = async (): Promise<IngestionConfigResponse> => {
|
|
const response = await fetch(`${API_BASE_URL}/ingestion/config`);
|
|
return (await response.json()) as IngestionConfigResponse;
|
|
};
|
|
|
|
export const setOpenAIConfig = async (apiKey: string, model?: string): Promise<ApiSuccessResponse> => {
|
|
const response = await fetch(`${API_BASE_URL}/ingestion/config/openai`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ apiKey, model })
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Config update failed");
|
|
}
|
|
return (await response.json()) as ApiSuccessResponse;
|
|
};
|
|
|
|
export const startIngestion = async (): Promise<ApiSuccessResponse> => {
|
|
const response = await fetch(`${API_BASE_URL}/ingestion/start`, { method: "POST" });
|
|
return (await response.json()) as ApiSuccessResponse;
|
|
};
|
|
|
|
export const diagnoseUpload = async (
|
|
file: File
|
|
): Promise<DiagnoseUploadResponse> => {
|
|
const form = new FormData();
|
|
form.append("planning", file);
|
|
const resp = await fetch(`${API_BASE_URL}/uploads/diagnose`, { method: "POST", body: form });
|
|
if (!resp.ok) throw new Error("Diagnostic failed");
|
|
return (await resp.json()) as DiagnoseUploadResponse;
|
|
};
|
|
|
|
export const repairIngestion = async (): Promise<RepairIngestionResponse> => {
|
|
const resp = await fetch(`${API_BASE_URL}/ingestion/repair`, { method: "POST" });
|
|
return (await resp.json()) as RepairIngestionResponse;
|
|
};
|
|
|
|
// Parents API
|
|
export type ParentPayload = { fullName: string; colorHex: string; email?: string; notes?: string; avatar?: ChildProfile["avatar"] };
|
|
export const listParents = async (): Promise<Array<{ id: string; fullName: string; colorHex: string; email?: string; notes?: string; avatar?: ChildProfile["avatar"]; }>> =>
|
|
apiClient.get("/parents");
|
|
export const createParent = async (payload: ParentPayload) => apiClient.post("/parents", payload);
|
|
export const updateParent = async (parentId: string, payload: Partial<ParentPayload> & { avatar?: ChildProfile["avatar"] | null }) =>
|
|
apiClient.patch(`/parents/${parentId}`, payload);
|
|
export const deleteParent = async (parentId: string) => apiClient.delete(`/parents/${parentId}`);
|
|
|
|
export type GrandParentPayload = ParentPayload;
|
|
export const listGrandParents = async (): Promise<Array<{ id: string; fullName: string; colorHex: string; email?: string; notes?: string; avatar?: ChildProfile["avatar"]; }>> =>
|
|
apiClient.get("/grandparents");
|
|
export const createGrandParent = async (payload: GrandParentPayload) => apiClient.post("/grandparents", payload);
|
|
export const updateGrandParent = async (grandParentId: string, payload: Partial<GrandParentPayload> & { avatar?: ChildProfile["avatar"] | null }) =>
|
|
apiClient.patch(`/grandparents/${grandParentId}`, payload);
|
|
export const deleteGrandParent = async (grandParentId: string) => apiClient.delete(`/grandparents/${grandParentId}`);
|
|
|
|
// Calendar integrations
|
|
type CalendarOAuthStartResponse = {
|
|
authUrl?: string;
|
|
url?: string;
|
|
authorizeUrl?: string;
|
|
state?: string;
|
|
};
|
|
|
|
type CalendarOAuthCompleteResponse = {
|
|
success: boolean;
|
|
email?: string;
|
|
label?: string;
|
|
connectionId?: string;
|
|
profileId?: string;
|
|
};
|
|
|
|
export const startCalendarOAuth = async (
|
|
provider: CalendarProvider,
|
|
payload: { profileId: string; state: string }
|
|
) => apiClient.post<CalendarOAuthStartResponse>(`/calendar/${provider}/oauth/start`, payload);
|
|
|
|
export const completeCalendarOAuth = async (payload: {
|
|
provider: CalendarProvider;
|
|
state: string;
|
|
code?: string;
|
|
error?: string;
|
|
profileId?: string;
|
|
}) => apiClient.post<CalendarOAuthCompleteResponse>("/calendar/oauth/complete", payload);
|
|
|
|
export const connectCalendarWithCredentials = async (
|
|
provider: CalendarProvider,
|
|
payload: { profileId: string; email: string; password: string; label?: string; shareWithFamily?: boolean }
|
|
) => apiClient.post<{ connection: ConnectedCalendar }>(`/calendar/${provider}/credentials`, payload);
|
|
|
|
export const listCalendarConnections = async (profileId: string) =>
|
|
apiClient.get<ConnectedCalendar[]>(`/calendar/${profileId}/connections`);
|
|
|
|
export const refreshCalendarConnection = async (profileId: string, connectionId: string) =>
|
|
apiClient.post<{ status: CalendarConnectionStatus; lastSyncedAt?: string }>(
|
|
`/calendar/${profileId}/connections/${connectionId}/refresh`,
|
|
{}
|
|
);
|
|
|
|
export const removeCalendarConnection = async (profileId: string, connectionId: string) =>
|
|
apiClient.delete(`/calendar/${profileId}/connections/${connectionId}`);
|
|
|
|
// Holidays and Personal Leaves API
|
|
import type { Holiday, PersonalLeave, SchoolRegion } from "@family-planner/types";
|
|
|
|
export const getHolidays = async (region?: SchoolRegion, year?: number): Promise<{ holidays: Holiday[] }> => {
|
|
const params = new URLSearchParams();
|
|
if (region) params.append("region", region);
|
|
if (year) params.append("year", year.toString());
|
|
return apiClient.get(`/holidays?${params.toString()}`);
|
|
};
|
|
|
|
export const getPublicHolidays = async (year?: number): Promise<{ holidays: Holiday[] }> => {
|
|
const params = new URLSearchParams();
|
|
if (year) params.append("year", year.toString());
|
|
return apiClient.get(`/holidays/public?${params.toString()}`);
|
|
};
|
|
|
|
export const getSchoolHolidays = async (region?: SchoolRegion, year?: number): Promise<{ holidays: Holiday[] }> => {
|
|
const params = new URLSearchParams();
|
|
if (region) params.append("region", region);
|
|
if (year) params.append("year", year.toString());
|
|
return apiClient.get(`/holidays/school?${params.toString()}`);
|
|
};
|
|
|
|
export const getPersonalLeaves = async (profileId?: string): Promise<{ leaves: PersonalLeave[] }> => {
|
|
const params = new URLSearchParams();
|
|
if (profileId) params.append("profileId", profileId);
|
|
return apiClient.get(`/personal-leaves?${params.toString()}`);
|
|
};
|
|
|
|
|
|
|
|
|