feat(web): typed API client + TanStack Query hooks
This commit is contained in:
parent
3fd45fe54d
commit
53755e9c3a
6 changed files with 420 additions and 0 deletions
15
src/api/client.test.ts
Normal file
15
src/api/client.test.ts
Normal 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
39
src/api/client.ts
Normal 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
205
src/api/hooks/mutations.ts
Normal 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
125
src/api/hooks/reads.ts
Normal 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
14
src/api/keys.ts
Normal 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
22
src/api/queryClient.tsx
Normal 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 };
|
||||
Loading…
Add table
Reference in a new issue