feat(web): typed API client + TanStack Query hooks

This commit is contained in:
Munkherdene 2026-08-22 20:24:53 +08:00
parent 3fd45fe54d
commit 53755e9c3a
6 changed files with 420 additions and 0 deletions

15
src/api/client.test.ts Normal file
View file

@ -0,0 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { apiGet } from "./client";
import { NetWorthSchema } from "./schemas";
beforeEach(() => vi.restoreAllMocks());
describe("apiGet", () => {
it("fetches and validates", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ assets: "1", liabilities: "0", total: "1" }), { status: 200 })));
const nw = await apiGet("/networth", NetWorthSchema);
expect(nw.total).toBe("1");
});
it("throws on http error", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 401 })));
await expect(apiGet("/networth", NetWorthSchema)).rejects.toThrow();
});
});

39
src/api/client.ts Normal file
View file

@ -0,0 +1,39 @@
import { z } from "zod";
export class ApiError extends Error {
constructor(
public status: number,
msg: string,
) {
super(msg);
}
}
async function req(method: string, path: string, body?: unknown): Promise<unknown> {
const res = await fetch(`/api/v1${path}`, {
method,
credentials: "same-origin",
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 401) {
if (typeof window !== "undefined") window.location.href = "/login";
throw new ApiError(401, "unauthorized");
}
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.status === 204 ? null : res.json();
}
export async function apiGet<T>(path: string, schema: z.ZodType<T>): Promise<T> {
return schema.parse(await req("GET", path));
}
export async function apiSend<T>(
method: "POST" | "PUT" | "DELETE",
path: string,
body?: unknown,
schema?: z.ZodType<T>,
): Promise<T | null> {
const data = await req(method, path, body);
return schema ? schema.parse(data) : (data as T);
}

205
src/api/hooks/mutations.ts Normal file
View file

@ -0,0 +1,205 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiSend } from "../client";
import { keys } from "../keys";
import {
BudgetSchema,
SettingsSchema,
LendingSchema,
RevalueResultSchema,
SubscriptionSchema,
} from "../schemas";
// --- transactions ---
export const useCategorize = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (b: { matchKey: string; category: string; kind?: "income" | "expense" }) =>
apiSend("POST", "/transactions/categorize", b),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["analyze"] });
qc.invalidateQueries({ queryKey: keys.budget });
},
});
};
export const useRenameTxn = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (b: { matchKey: string; name: string }) => apiSend("POST", "/transactions/rename", b),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
};
export const useSetNote = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (b: { id: number; note: string }) => apiSend("PUT", `/transactions/${b.id}/note`, { note: b.note }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
};
// --- budget ---
export const usePutBudget = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (b: {
dayLimit: string;
weekLimit: string;
monthLimit: string;
plannedIncomeManual: string;
categories: { name: string; day: string; week: string; month: string }[];
}) => apiSend("PUT", "/budget", b, BudgetSchema),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.budget }),
});
};
// --- settings ---
export const useSaveSettings = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (b: {
holderName: string;
employer: string;
salaryKeywords: string[];
payDays: number[];
ownAccounts: string[];
peerAccounts: string[];
hideAmounts?: boolean;
}) => apiSend("PUT", "/settings", b, SettingsSchema),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.settings }),
});
};
// --- categories ---
export const useCategoryMutations = () => {
const qc = useQueryClient();
const invalidate = () => {
qc.invalidateQueries({ queryKey: keys.categories });
qc.invalidateQueries({ queryKey: keys.budget });
qc.invalidateQueries({ queryKey: ["transactions"] });
};
const add = useMutation({
mutationFn: (b: { name: string; kind?: "income" | "expense"; parent?: string; icon?: string }) =>
apiSend("POST", "/categories", b),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: (b: { oldName: string; newName?: string; parent?: string | null; icon?: string | null }) =>
apiSend("PUT", "/categories", b),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (name: string) => apiSend("DELETE", `/categories?name=${encodeURIComponent(name)}`),
onSuccess: invalidate,
});
return { add, update, delete: remove };
};
// --- manual assets ---
export const useManualAssetMutations = () => {
const qc = useQueryClient();
const invalidate = () => {
qc.invalidateQueries({ queryKey: keys.manualAssets });
qc.invalidateQueries({ queryKey: keys.networth });
};
const add = useMutation({
mutationFn: (b: {
name: string;
category?: string;
value: string;
acquiredValue?: string;
currency?: string;
condition?: string;
isLiability?: boolean;
}) => apiSend("POST", "/manual-assets", b),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (name: string) => apiSend("DELETE", `/manual-assets/${encodeURIComponent(name)}`),
onSuccess: invalidate,
});
const revalue = useMutation({
mutationFn: (name: string) => apiSend("POST", `/manual-assets/${encodeURIComponent(name)}/revalue`, undefined, RevalueResultSchema),
onSuccess: invalidate,
});
return { add, delete: remove, revalue };
};
// --- lending ---
export const useLendingMutations = () => {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: keys.lending });
const create = useMutation({
mutationFn: (b: { person: string; amount: string; lentOn: string; dueOn?: string; note?: string; txnId?: number }) =>
apiSend("POST", "/lending", b, LendingSchema),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: number) => apiSend("DELETE", `/lending/${id}`),
onSuccess: invalidate,
});
const addRepayment = useMutation({
mutationFn: (b: { id: number; amount: string; paidOn: string; note?: string; txnId?: number }) =>
apiSend("POST", `/lending/${b.id}/repayments`, { amount: b.amount, paidOn: b.paidOn, note: b.note, txnId: b.txnId }, LendingSchema),
onSuccess: invalidate,
});
const deleteRepayment = useMutation({
mutationFn: (b: { id: number; repaymentId: number }) => apiSend("DELETE", `/lending/${b.id}/repayments/${b.repaymentId}`),
onSuccess: invalidate,
});
return { create, delete: remove, addRepayment, deleteRepayment };
};
// --- subscriptions ---
export const useSubscriptionMutations = () => {
const qc = useQueryClient();
const invalidate = () => {
qc.invalidateQueries({ queryKey: keys.subscriptions });
qc.invalidateQueries({ queryKey: keys.budget });
};
const setSubscription = useMutation({
mutationFn: (b: { matchKey: string; name?: string; active: boolean }) => apiSend("POST", "/subscriptions", b),
onSuccess: invalidate,
});
const createManualSub = useMutation({
mutationFn: (b: { name: string; amount: string; category?: string; nextDue?: string }) =>
apiSend("POST", "/manual-subscriptions", b, SubscriptionSchema),
onSuccess: invalidate,
});
const deleteManualSub = useMutation({
mutationFn: (id: number) => apiSend("DELETE", `/manual-subscriptions/${id}`),
onSuccess: invalidate,
});
return { setSubscription, createManualSub, deleteManualSub };
};
// --- savings goals ---
export const useSavingsGoalMutations = () => {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: keys.budget });
const post = useMutation({
mutationFn: (b: {
originalName?: string;
name: string;
target: string;
monthlyContribution?: string;
accountId?: number;
targetDate?: string;
}) => apiSend("POST", "/savings-goals", b, BudgetSchema),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (name: string) => apiSend("DELETE", `/savings-goals/${encodeURIComponent(name)}`, undefined, BudgetSchema),
onSuccess: invalidate,
});
return { post, delete: remove };
};

125
src/api/hooks/reads.ts Normal file
View file

@ -0,0 +1,125 @@
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { apiGet } from "../client";
import { keys } from "../keys";
import {
NetWorthSchema,
AnalyzeSchema,
BudgetSchema,
TxnSchema,
CategorySchema,
SubscriptionSchema,
ManualAssetSchema,
LendingSchema,
SettingsSchema,
ConnectionSchema,
UserSchema,
} from "../schemas";
/** Local date (not UTC) as YYYY-MM-DD dependency-free, matches the backend's
* `?from=&to=` parsing (time.Parse("2006-01-02", v)). */
export function todayLocalDate(d: Date = new Date()): string {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export const useNetWorth = () =>
useQuery({ queryKey: keys.networth, queryFn: () => apiGet("/networth", NetWorthSchema) });
export const useAnalyzeMonth = () =>
useQuery({ queryKey: keys.analyzeMonth, queryFn: () => apiGet("/analyze", AnalyzeSchema) });
export const useAnalyzeToday = () => {
const d = todayLocalDate();
return useQuery({
queryKey: keys.analyzeToday(d),
queryFn: () => apiGet(`/analyze?from=${d}&to=${d}`, AnalyzeSchema),
});
};
export const useBudget = () => useQuery({ queryKey: keys.budget, queryFn: () => apiGet("/budget", BudgetSchema) });
export interface TransactionsParams {
from?: string;
to?: string;
direction?: "income" | "expense";
category?: string;
account?: number | string;
limit?: number;
}
function txnQueryString(params?: TransactionsParams): string {
if (!params) return "";
const qs = new URLSearchParams();
if (params.from) qs.set("from", params.from);
if (params.to) qs.set("to", params.to);
if (params.direction) qs.set("direction", params.direction);
if (params.category) qs.set("category", params.category);
if (params.account !== undefined) qs.set("account", String(params.account));
if (params.limit !== undefined) qs.set("limit", String(params.limit));
const s = qs.toString();
return s ? `?${s}` : "";
}
const TransactionsResponseSchema = z.object({ transactions: z.array(TxnSchema) });
export const useTransactions = (params?: TransactionsParams) => {
const qs = txnQueryString(params);
return useQuery({
queryKey: keys.transactions(qs),
queryFn: async () => (await apiGet(`/transactions${qs}`, TransactionsResponseSchema)).transactions,
});
};
const CategoriesResponseSchema = z.object({ categories: z.array(CategorySchema) });
export const useCategories = () =>
useQuery({
queryKey: keys.categories,
queryFn: async () => (await apiGet("/categories", CategoriesResponseSchema)).categories,
});
const SubscriptionsResponseSchema = z.object({
subscriptions: z.array(SubscriptionSchema),
bills: z.array(SubscriptionSchema),
});
export const useSubscriptions = () =>
useQuery({ queryKey: keys.subscriptions, queryFn: () => apiGet("/subscriptions", SubscriptionsResponseSchema) });
const ManualAssetsResponseSchema = z.object({ manualAssets: z.array(ManualAssetSchema) });
export const useManualAssets = () =>
useQuery({
queryKey: keys.manualAssets,
queryFn: async () => (await apiGet("/manual-assets", ManualAssetsResponseSchema)).manualAssets,
});
const LendingResponseSchema = z.object({ entries: z.array(LendingSchema) });
export const useLending = () =>
useQuery({
queryKey: keys.lending,
queryFn: async () => (await apiGet("/lending", LendingResponseSchema)).entries,
});
export const useSettings = () =>
useQuery({ queryKey: keys.settings, queryFn: () => apiGet("/settings", SettingsSchema) });
const ConnectionsResponseSchema = z.object({ connections: z.array(ConnectionSchema) });
export const useConnections = () =>
useQuery({
queryKey: keys.connections,
queryFn: async () => (await apiGet("/connections", ConnectionsResponseSchema)).connections,
});
const MeResponseSchema = z.object({ user: UserSchema });
export const useMe = () =>
useQuery({
queryKey: keys.me,
queryFn: async () => (await apiGet("/me", MeResponseSchema)).user,
});

14
src/api/keys.ts Normal file
View file

@ -0,0 +1,14 @@
export const keys = {
networth: ["networth"] as const,
analyzeMonth: ["analyze", "month"] as const,
analyzeToday: (d: string) => ["analyze", "today", d] as const,
budget: ["budget"] as const,
transactions: (p: string) => ["transactions", p] as const,
categories: ["categories"] as const,
subscriptions: ["subscriptions"] as const,
manualAssets: ["manualAssets"] as const,
lending: ["lending"] as const,
settings: ["settings"] as const,
connections: ["connections"] as const,
me: ["me"] as const,
} as const;

22
src/api/queryClient.tsx Normal file
View file

@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
// Module-level singleton: one QueryClient shared across the app (client-side
// only — Next's RSC boundary means this module is only ever instantiated in
// the browser via the "use client" directive above).
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
export function AppQueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
export { queryClient };