129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { z } from "zod";
|
|
import { apiGet, apiSend, ApiError } from "../client";
|
|
import { queryClient } from "../queryClient";
|
|
import { keys } from "../keys";
|
|
|
|
// --- wire schemas (kept local to this hook, per scope) ---
|
|
|
|
const JobRefSchema = z.object({ bank: z.string(), jobId: z.number() });
|
|
const LockedRefSchema = z.object({ bank: z.string(), retryAfterSeconds: z.number() });
|
|
|
|
// Note: fields below are `.optional()` rather than `.default(...)` — apiGet's
|
|
// `z.ZodType<T>` param type unifies output/input into one T, and zod's
|
|
// ZodDefault input type (optional) leaks into T when it's inferred that way.
|
|
// Optional + a `?? fallback` at each call site keeps the inferred type honest.
|
|
const SyncResponseSchema = z.object({
|
|
jobs: z.array(JobRefSchema).optional(),
|
|
locked: z.array(LockedRefSchema).optional(),
|
|
});
|
|
|
|
const SyncJobSchema = z.object({
|
|
id: z.number(),
|
|
bank: z.string(),
|
|
status: z.enum(["pending", "done", "error"]),
|
|
newCount: z.number().optional(),
|
|
error: z.string().optional(),
|
|
});
|
|
type SyncJob = z.infer<typeof SyncJobSchema>;
|
|
|
|
export interface SyncError {
|
|
bank: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface SyncResult {
|
|
/** True when POST /sync came back 400 "no banks connected" — nothing was enqueued. */
|
|
noBanksConnected: boolean;
|
|
/** Sum of new_count across every completed job. */
|
|
newCount: number;
|
|
/** Jobs that finished with status "error" (or timed out still pending). */
|
|
errors: SyncError[];
|
|
/** Banks the server declined to enqueue this round (already syncing). */
|
|
locked: { bank: string; retryAfterSeconds: number }[];
|
|
}
|
|
|
|
const POLL_INTERVAL_MS = 1500;
|
|
const MAX_POLLS = 40; // ~60s ceiling
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/** Polls one sync job until it leaves "pending", capped at MAX_POLLS attempts.
|
|
* A job still pending at the cap is surfaced to the caller as an error (rather
|
|
* than hanging the UI indefinitely) so a stuck bank sync can't spin forever. */
|
|
export async function pollJobUntilDone(jobId: number): Promise<SyncJob> {
|
|
let last: SyncJob | null = null;
|
|
for (let i = 0; i < MAX_POLLS; i++) {
|
|
last = await apiGet(`/sync/jobs/${jobId}`, SyncJobSchema);
|
|
if (last.status !== "pending") return last;
|
|
if (i < MAX_POLLS - 1) await sleep(POLL_INTERVAL_MS);
|
|
}
|
|
return last ?? { id: jobId, bank: "", status: "error" as const, newCount: 0, error: "timeout" };
|
|
}
|
|
|
|
/** Invalidates every read query the dashboard/pages depend on so a completed
|
|
* sync's fresh data (new transactions, updated balances/budget/etc.) shows up
|
|
* without a manual reload. */
|
|
function invalidateAllReads() {
|
|
queryClient.invalidateQueries({ queryKey: keys.networth });
|
|
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
|
queryClient.invalidateQueries({ queryKey: ["analyze"] }); // covers both analyzeMonth + analyzeToday
|
|
queryClient.invalidateQueries({ queryKey: keys.budget });
|
|
queryClient.invalidateQueries({ queryKey: keys.connections });
|
|
queryClient.invalidateQueries({ queryKey: keys.manualAssets });
|
|
queryClient.invalidateQueries({ queryKey: keys.subscriptions });
|
|
queryClient.invalidateQueries({ queryKey: keys.categories });
|
|
queryClient.invalidateQueries({ queryKey: keys.me });
|
|
}
|
|
|
|
/** Triggers a server-side bank sync (POST /sync), polls every returned job to
|
|
* completion, then refreshes all read queries. See internal/transport/api/sync_handlers.go
|
|
* for the backend contract this mirrors. */
|
|
export function useSync() {
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [result, setResult] = useState<SyncResult | null>(null);
|
|
const runningRef = useRef(false);
|
|
|
|
const sync = useCallback(async () => {
|
|
if (runningRef.current) return;
|
|
runningRef.current = true;
|
|
setIsSyncing(true);
|
|
setResult(null);
|
|
try {
|
|
let res: z.infer<typeof SyncResponseSchema>;
|
|
try {
|
|
res = (await apiSend("POST", "/sync", undefined, SyncResponseSchema)) as z.infer<
|
|
typeof SyncResponseSchema
|
|
>;
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 400) {
|
|
setResult({ noBanksConnected: true, newCount: 0, errors: [], locked: [] });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const jobs = res.jobs ?? [];
|
|
const jobResults = await Promise.all(jobs.map((j) => pollJobUntilDone(j.jobId)));
|
|
|
|
let newCount = 0;
|
|
const errors: SyncError[] = [];
|
|
for (const j of jobResults) {
|
|
if (j.status === "error") errors.push({ bank: j.bank, message: j.error || "sync_failed" });
|
|
else newCount += j.newCount ?? 0;
|
|
}
|
|
|
|
invalidateAllReads();
|
|
setResult({ noBanksConnected: false, newCount, errors, locked: res.locked ?? [] });
|
|
} finally {
|
|
runningRef.current = false;
|
|
setIsSyncing(false);
|
|
}
|
|
}, []);
|
|
|
|
return { sync, isSyncing, result };
|
|
}
|