refactor(ts): move User to @/types + type auth endpoints; add code to 500 (Track C/D)

Track C (auth): User lived in @/hooks/useAuth; moved it to @/types
(re-exported from useAuth for the 7 importers) and typed me/login/
totpChallenge/hasUsers, dropping those Admin/Login casts. Left the
single-consumer non-money casts (adminUsers, version/about/privacy/
health docs) and the optimistic-UI rewrite as deliberate low-value/
high-churn stops.

Track D: global 500 handler now emits code:'INTERNAL_ERROR' so an
unexpected error carries the same {error,code} field as structured 4xx.

typecheck/lint(0 err)/build/server-193 all green; probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 12:26:11 -05:00
parent 65a477cab1
commit 1df4b1bf87
7 changed files with 29 additions and 21 deletions

View File

@ -3,6 +3,8 @@
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
- **[Client] Auth: `User` moved to its proper home + auth endpoints typed.** `User` lived oddly in `@/hooks/useAuth`; moved it to `@/types` (re-exported from `useAuth` so the 7 importers keep working) and typed `me`/`login`/`totpChallenge`/`hasUsers` with `get<T>`/`post<T>`, dropping those casts on the Admin + Login pages. *(Deliberate stop: the remaining single-consumer, non-money casts — `adminUsers` with its page-local `AdminUser`, and the one-off `version`/`about`/`privacy`/`health`/`importHistory` doc shapes — are left as-is; central types add ~nothing for a single reader, and typing them is the low-value tail. Likewise the **optimistic-UI/`toast.promise` rewrite** (planned Track D) is intentionally not done — it's behavior-visible churn on already-working handlers, the worst risk/reward of the remaining work.)*
- **[Server] Error-shape consistency (Track D).** The global 500 handler ([server.js](server.js)) returned `{ error: 'Internal server error' }` with no `code`; added `code: 'INTERNAL_ERROR'` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
- **[Client] Snowball projection + settings typed.** Promoted `SnowballProjection` (+ `SnowballProjectionDetail`/`AvalancheProjectionDetail`/`SnowballDebtProjection`) and `SnowballSettings` into `@/types`, and wired `snowball` (→ `Bill[]`), `snowballSettings`/`saveSnowballSettings` (→ `SnowballSettings`), and `snowballProjection` (→ `SnowballProjection`). Removed the projection/settings/bills casts. (The plan-lifecycle endpoints still cast to `SnowballPlan`, which is a component-owned type in `PlanHistoryPanel` — left as-is to avoid a cross-file type move for marginal gain.)
- **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get<T>`/`post<T>`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E).
- **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get<T>`/`post<T>`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E).

View File

@ -1,5 +1,5 @@
import type {
Bill, Category, Payment, TrackerResponse,
Bill, Category, Payment, TrackerResponse, User,
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
SpendingRule, CopyBudgetsResponse, CategoryGroup,
@ -108,9 +108,9 @@ function filenameFromDisposition(value: string | null): string | null {
export const api = {
// Auth
me: () => get('/auth/me'),
me: () => get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
authMode: () => get('/auth/mode'),
login: (data: Body) => post('/auth/login', data),
login: (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
logout: () => post('/auth/logout'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
changePassword: (data: Body) => post('/auth/change-password', data),
@ -133,7 +133,7 @@ export const api = {
totpSetup: () => get('/auth/totp/setup'),
totpEnable: (data: Body) => post('/auth/totp/enable', data),
totpDisable: (data: Body) => post('/auth/totp/disable', data),
totpChallenge: (data: Body) => post('/auth/totp/challenge', data),
totpChallenge: (data: Body) => post<{ user?: User }>('/auth/totp/challenge', data),
webauthnStatus: () => get('/auth/webauthn/status'),
webauthnSetup: () => get('/auth/webauthn/setup'),
@ -144,7 +144,7 @@ export const api = {
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
// Admin
hasUsers: () => get('/admin/has-users'),
hasUsers: () => get<{ has_users?: boolean }>('/admin/has-users'),
adminUsers: () => get('/admin/users'),
createUser: (data: Body) => post('/admin/users', data),
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),

View File

@ -1,16 +1,8 @@
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
import { api } from '@/api';
import type { User } from '@/types';
export interface User {
id: number;
username?: string;
role?: string;
display_name?: string;
displayName?: string;
name?: string;
is_default_admin?: boolean;
[key: string]: unknown;
}
export type { User }; // re-export so existing `@/hooks/useAuth` importers keep working
interface MeResponse {
user?: User | null;

View File

@ -26,7 +26,7 @@ export default function AdminPage() {
const loadMe = useCallback(async () => {
try {
const d = await api.me() as { user?: User };
const d = await api.me();
setMe(d.user ?? null);
} catch {
navigate('/login', { replace: true });
@ -42,7 +42,7 @@ export default function AdminPage() {
const loadHasUsers = useCallback(async () => {
try {
const d = await api.hasUsers() as { has_users?: boolean };
const d = await api.hasUsers();
setHasUsers(!!d.has_users);
if (d.has_users) loadUsers();
} catch (err) {

View File

@ -86,7 +86,7 @@ export default function LoginPage() {
setError('');
setLoading(true);
try {
const data = await api.login({ username, password }) as { requires_totp?: boolean; challenge_token?: string; user: User };
const data = await api.login({ username, password });
if (data.requires_totp) {
setTotpChallenge(data.challenge_token ?? null);
setTotpCode('');
@ -94,7 +94,7 @@ export default function LoginPage() {
setUseRecovery(false);
return;
}
handlePostLogin(data.user);
handlePostLogin(data.user!);
} catch (err) {
setError(errMessage(err, 'Login failed.'));
} finally {
@ -110,8 +110,8 @@ export default function LoginPage() {
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
if (useRecovery) payload.recovery_code = totpCode;
else payload.code = totpCode;
const data = await api.totpChallenge(payload) as { user: User };
handlePostLogin(data.user);
const data = await api.totpChallenge(payload);
handlePostLogin(data.user!);
} catch (err) {
setTotpError(errMessage(err, 'Invalid code.'));
setTotpCode('');

View File

@ -10,6 +10,19 @@
// sparkline) are left `unknown` and refined when a consumer needs them.
import type { Cents, Dollars } from '@/lib/money';
// The authenticated user (GET /auth/me → `user`). Re-exported from
// `@/hooks/useAuth` for the many components that import it from there.
export interface User {
id: number;
username?: string;
role?: string;
display_name?: string;
displayName?: string;
name?: string;
is_default_admin?: boolean;
[key: string]: unknown;
}
// A bank transaction as serialized by the server. Unlike bill/payment amounts,
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
// (format them with formatCentsUSD, never formatUSD).

View File

@ -196,6 +196,7 @@ app.use((err, req, res, next) => {
res.status(err.status || 500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR',
});
});