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