v2 architecture: TypeScript server, schema migration, client polish#102
Merged
Conversation
…m /me - add GET /api/expenses/feed with server-side filtering (page, limit, month, year, search, category) via aggregation pipeline; powers the client useInfiniteQuery on Reports - switch edit/delete expense to flat /api/expenses/products/:productId routes (parent doc is located server-side by actualDate); matches what the dialog callers already know - trim /me payload to user identity + currentSession only; pocket-money and lent-money histories ride on their own endpoints via dedicated queries
- shared/api: typed endpoints registry + ApiResponse<T> envelope
- shared/lib/queryKeys: factory for every TanStack Query key
- shared/components/form: FormField, PasswordInput, SubmitButton,
SelectField — replace the duplicated input boilerplate across forms
- shared/components/table/DataTable: generic wrapper over
@tanstack/react-table; replaces ~60 LOC of header/body markup repeated
per table
- shared/components/charts/ChartFilterOptions: extracted from
components/user/common
- shared/hooks: useDialogState, useDebounce, useMediaQuery (with
useIsMobile)
- shared/contexts: ThemeProvider + SidebarProvider that replace the old
themeMode and sideNavbar Redux slices
- lib/http: error envelope helpers (getApiErrorMessage,
getApiFieldErrors) for surfacing server field-level errors into forms
- App + main: drop redux Provider, wire ThemeProvider + SidebarProvider
- types/api/*: sync to new server response shapes; split AuthResType
(login/register/google) from UserDetailsResType (/me)
- install react-hook-form, @hookform/resolvers, zod; remove react-redux
and @reduxjs/toolkit
- services/auth + adminAccess: switch every URL to the new REST routes
- delete legacy services/{expenses,lentmoney,reports} now that each
feature owns its API directly
- features/auth: LoginForm, SignupForm, ForgotPasswordForm,
ResetPasswordForm, GoogleAuthButton — all on react-hook-form +
zodResolver with field-level error messages (replaces 100-220 LOC
Formik components each)
- features/auth/hooks: useLogin / useSignup / useGoogleSignIn share a
useHandleAuthSuccess that sets the cookie and seeds the /me query
cache; useLogout clears the cache + cookie + google session
- features/auth/schemas: Zod schemas with shared username / email /
password primitives; types come from z.infer<typeof schema>
- features/user/hooks: useMe() — single source of truth for the current
user, replacing the Redux user slice
- delete legacy components/auth/{LoginSection, SignupSection,
ForgotPasswordSection, ResetPassword, GoogleAuthLogin, AuthFooter}
- schemas/userAuth: trimmed to just the landing-page contact form (Yup
is otherwise gone)
- delete features/user/user.ts (Redux slice)
- pages/MainLayout: reads useMe() directly; no longer mirrors into Redux
- hooks/useSentryIntegration: identifies the user from useMe()
- routes/routes: point at the new auth form components
… lent / sessions
- features/pocketMoney: api + Zod schema + useAddPocketMoney /
usePocketMoneyHistory + AddForm + HistoryTable + CurrentBalanceCard
(renamed from ShowMoney.tsx)
- features/lentMoney: api + Zod schema + useAddLentMoney /
useLentMoneyHistory / useMarkLentMoneyReceived + AddForm +
HistoryTable + MarkReceivedDialog (renamed from
ReceivedLentMoneyDialog); table shows Pending/Received status badges
thanks to the new isReceived flag (no more $pull-on-receive that
destroyed history)
- features/sessions: api + useSessions (proper useQuery, not the legacy
mutation-used-as-read) + SessionsPopover + SessionCard +
LogOutOtherDevicesButton (matches the new server behaviour: deletes
other devices only; clearer label) + skeleton
- pages/user/addMoney + addLentMoney: thin composition only
- rename pages/user/addLentMoney/AddLentMoney..tsx -> AddLentMoney.tsx
(drop the double dot in the filename)
- delete legacy components/user/addMoney, components/user/addLentMoney,
components/profile/sessions, hooks/auth/useUserLogout — all replaced
by the new feature hooks
- every mutation invalidates qk.{pocketMoney,lentMoney,sessions}.all
+ qk.me so the running balance refreshes
… reports feed
- features/expenses: api + Zod schemas (addExpenseFormSchema,
editExpenseFormSchema) + hooks (useTodayExpenses, useExpensesByDate,
useAllExpenses, useAddExpenses, useEditExpense, useDeleteExpense)
- features/expenses/components: AddExpenseForm + EditExpenseDialog (both
RHF + Zod), DeleteExpenseDialog (with refund-to-balance checkbox),
ExpensesTable backed by shared DataTable, TodayExpensesPanel +
ExpensesByDatePanel (uses useQuery instead of the old
useMutation-for-a-read pattern)
- every mutation invalidates qk.expenses.all + qk.me — fixes the
long-standing cache-coherency bug where deleting an expense didn't
refresh the all-expenses view
- features/reports: useInfiniteQuery against the new
GET /api/expenses/feed endpoint; ReportsPanel + ReportsFilters +
ReportsTable with IntersectionObserver-driven auto-load. Page size 10
— addresses the perf hot-spot where the Reports page used to load
~12k-22k rows on first paint
- pages/user/{addExpenses,showExpenses,reports}: thin composition only
- delete schemas/expenses.tsx (Yup version)
- delete legacy components/user/{addExpenses,showExpenses,reports} —
the components were 200-500 LOC each and full of branching,
prop-drilling, and Redux dispatches; the replacements are 50-150 LOC
with hooks owning the data
…it charts - features/dashboard/api + hooks: useMonthlyReport(month, year) backed by a useQuery keyed on the filter pair, so changing month/year auto- refetches and jumping back to a prior filter is instant from cache. Replaces the legacy useMutation-in-useEffect pattern - features/dashboard/components: DashboardFilters, SummaryBoxes, CategoryDonutChart, ExpensesTimelineChart, CategoryInsightsTable, ViewCategoryExpensesDialog, plus the donut/line skeleton loaders - features/dashboard/hooks/useCategoryStats: properly hook-scoped (replaces the legacy getFullExpensesList Rules-of-Hooks violation where useSelector was called from inside a table-cell renderer); reads from the shared useAllExpenses query so the insights table and timeline chart share one network round-trip - features/dashboard/lib/categoryColors: single source of truth for the category gradients (was duplicated inline in the legacy donut chart) - pages/user/dashboard/Dashboard: slimmed from ~175 LOC of state and state-syncing to ~70 LOC of composition - delete components/user/dashbaord/ (note the typo'd folder name — fixed by being deleted)
…o new model
- features/profile: ProfileForm (RHF + Zod, 14 useState → 1 form hook),
AvatarSection, AdvancedOptions, DeleteAccountDialog; useUpdateProfile
/ useUpdateAvatar / useDeleteAccount invalidate qk.me
- features/layout/header: TopHeader (shell only) + HeaderActions
(right-hand button cluster) + NotificationsPopover (account-verified
banner)
- features/layout/sidebar/SideNavbar: renamed from
components/navbar/SideNavbar; reads sidebar state from the new
SidebarContext, uses the new useLogout
- pages/user/Profile/ProfilePage: 490 LOC → ~30 LOC composition
- pages/user/UserLayout + pages/admin/AdminLayout: read sidebar/mobile
state from SidebarContext + useIsMobile; user identity from useMe()
- components/admin/{UserDetails, NewsLetter}: drop references to
PocketMoneyHistory / LentMoneyHistory (server no longer embeds those
on the User model); replace useSelector reads with useMe / useTheme
- components/user/PDFExportComponent: pulls user via useMe()
- components/ui/theme-toggle-button: uses the new useTheme() context
- delete pages/user/Profile/DeleteAccountDIalog.tsx (typo'd filename;
replaced by features/profile/components/DeleteAccountDialog.tsx)
- delete components/header/ + components/navbar/ + components/profile/
(now empty)
Every consumer migrated in earlier commits — user data lives in the /me query, theme + sidebar live in contexts, viewport state in a media-query hook. With nothing reading them anymore, the store and the four legacy slices can go: - delete app/store.ts (configureStore root) - delete features/theme/themeModeSlice.ts - delete features/sideNavbar/sideNavbarSlice.ts - delete features/windowWidth/windowWidthSlice.ts react-redux + @reduxjs/toolkit were already removed from package.json in the shared-infrastructure commit; this commit removes the last references on disk.
…der delete The components/navbar/ folder held two unrelated files: SideNavbar.tsx (moved to features/layout/sidebar/) and Navbar.tsx (the landing page header — out of scope for the refactor). The folder-wide delete in the profile+layout commit took both. HomePage.tsx still imports @/components/navbar/Navbar, so Vite was throwing a 500. Restoring Navbar.tsx verbatim from before the delete. No content changes.
…ors inline
- LoginForm sends username OR email based on '@' in input (server's Zod
.email() rejected username strings sent in the email field)
- server loginLocal builds $or from populated fields only — closes a
latent auth-bypass where Mongoose stripped `{ email: undefined }` to `{}`
- FormField/SelectField + all RHF forms now render role=alert error spans
below inputs and use mode='onTouched'
- DataTable: widen columns prop to `ColumnDef<T, any>[]` so mixed accessor types coexist (TanStack's second generic is invariant) - DatePicker: setInputDate accepts a plain callback, not just a setter - auth/hooks: wrap useHandleAuthSuccess to match TanStack's onSuccess sig - PDFExportComponent: DOB→dob and coalesce undefined fields for CellInput
…, env validation
- helmet sets standard security headers (CSP, HSTS, XFO, etc.)
- globalLimiter (120/min) on all routes; authLimiter (10/15min, failures only)
on /register, /login, /google, /password-reset/{request,confirm}
- express-mongo-sanitize strips $-prefixed keys to block operator injection
- config/env.js validates required env vars via Zod, fails fast with a clear
list if anything is missing — replaces silent jwt.verify(token, undefined)
- trust proxy reads TRUST_PROXY_HOPS (default 1 = nginx) instead of unsafe true
- index.js no longer references undefined `error` in app.on('error')
Pure mechanical move — no logic changes. Layer-based (controllers/, services/,
routes/, validators/, models/, middleware/, utils/) replaced with vertical
slices under modules/ + shared/.
- modules/{auth,user,expense,pocketMoney,lentMoney,session,report,admin}/ —
each owns model + controller + service + routes + validator
- shared/{middleware,lib,email,config,db}/ — cross-cutting infrastructure
- routes/index.js → routes.js
- utils/EmailSend.js → shared/email/email.service.js
- utils/getCurrentDate.js → shared/lib/date.js
- validators/common.js → shared/lib/validators.js
- models/expenses.model.js → modules/expense/expense.model.js
- models/activeSession.model.js → modules/session/session.model.js
All 53 renames preserve git history via git mv. 38 files updated to point at
new import paths. Server boots and /healthz responds 200.
Schema:
- User: currentPocketMoney String→Number, dateOfBirth→dob:Date, sparse
googleId index, dropped redundant username index
- PocketMoney/LentMoney/Expense: amount/price/date now Number/Date
- LentMoney: index {user, isReceived}; PocketMoney: index {user, createdAt}
- ActiveSession: TTL 30d on lastUsedAt
Services:
- adjustBalance uses atomic $inc (race-free under concurrent writes)
- report.service / expense.service use {date: {$gte, $lt}} ranges instead
of $split + regex string matching
- new helpers: monthRange, dayRange, startOfToday in shared/lib/date.js
Validators:
- dateString → dateInput (accepts Date/dd-mm-yyyy/ISO, emits Date)
- moneyAmount simplified to z.coerce.number().positive()
Migration:
- scripts/migrate-v2-types.js reads raw via collection driver, bulkWrites
String→Number money and dd-mm-yyyy/yy→Date conversions. Idempotent.
- npm run migrate:v2-types
Replaces the embedded-array Expense model (1 doc per (user, date) with
products[] inside) with one document per expense line. Eliminates the
$unwind in feed queries, makes edit/delete a single-doc op, and lets us
index {user, category, date} directly.
Server:
- expense.model.js: drops products[] subschema; hoists name/price/category/
label to the top level. New indexes {user, date}, {user, category, date},
{user, createdAt}.
- expense.service.js: insertMany for bulk; find/findOneAndUpdate/findOneAndDelete
for single-doc CRUD. getExpensesFeed drops the $unwind pipeline in favour
of a plain find + countDocuments.
- expense.controller + validator: drops actualDate (server resolves by _id);
responses now return flat docs.
- report.service: monthly category breakdown via $group on category.
Client:
- types/api/expenses: flat Expense replaces ExpenseEntry/ExpenseProduct
(kept as alias). All response shapes return Expense[].
- features/expenses/api: drops actualDate/expenseDate from edit/delete.
- ExpensesByDatePanel: data is now Expense[] instead of {products: []}.
- ReportsTable + Panel: row.product.X → row.X (no more nested product).
- Dashboard useCategoryStats + Timeline chart + ViewCategoryExpensesDialog:
date is ISO 8601 (parsed via new Date) instead of dd-mm-yyyy strings.
Migration:
- scripts/migrate-v2-expense-flatten.js: walks day-docs, $unwinds products[]
into individual flat docs preserving each product._id, then deletes the
source day-docs. Idempotent.
- npm run migrate:v2-flatten
…use resets
A6 — ActiveSession.token (plaintext JWT) → tokenHash (sha256). DB leak no
longer = impersonation. session.service hashes on write, look up by hash.
Migration: scripts/migrate-v2-session-hash.js.
C3 — verifyJwtToken stops loading the full User on every request. Just
decodes the JWT and looks up the session by hashed token (session existence
implies user existence via deleteAccount cascade). Drops req.user; sets
req.userId. All controllers updated.
B6 — User.role enum ('user' | 'admin'), requireAdmin middleware mounted on
/api/admin/* so a plain JWT is no longer enough to list all users or
trigger the newsletter blast.
B5 — Password reset is single-use. requestPasswordReset stamps a sha256 of
the issued JWT on User.passwordResetTokenHash. resetPassword rejects when
no pending reset is recorded, then clears the hash on success. Blocks the
prior gap where any caller with a userId could reset the password.
B8 — Multer caps avatar uploads at 5MB and rejects non-image MIME types.
userService.isVerified — small helper to keep auth.controller.checkVerified
working after dropping req.user.
npm run migrate:v2-session-hash
Admin panel will live in a separate SPA that consumes /api/admin/* across all projects. The user-facing client should not ship code that 99% of users never load. Removed: - pages/admin/, components/admin/, types/api/admin/ - data/AdminSidebarList.ts, services/adminAccess.ts - admin imports + route block in routes/routes.tsx - admin entries in endpoints.ts, queryKeys.ts, HeaderName.ts, utility.ts Bundle: 1713 → 1680 kB (~34 kB shaved). Server admin APIs and requireAdmin middleware untouched — ready for the separate admin SPA.
…n, DTO mapper
E2 Graceful shutdown: SIGTERM/SIGINT close the HTTP server, await in-flight
requests, disconnect Mongoose, then exit. 10s force-exit watchdog. Also
catches unhandledRejection + uncaughtException.
E3 Structured logger via pino (pretty in dev, JSON in prod). Replaces
console.log/console.error in db/conn, email service, cloudinary, auth
service, error middleware. Morgan dependency removed (pino-http takes
over request logging; healthz auto-log suppressed to keep noise down).
E4 Request-id middleware honors inbound x-request-id (gateway/LB) or mints
one via nanoid, echoes it on the response header, and threads it through
pino-http's child logger so every log line for a request shares the id.
C4 getMe response built via a whitelist DTO mapper (ME_FIELDS) so adding a
User field doesn't accidentally leak it to /me. Also fixed a latent bug
from Phase 5: getMe was still querying ActiveSession by {token} after
the field was renamed to {tokenHash}, so currentSession was always null.
Deps: +pino, +pino-pretty, +pino-http, +nanoid; -morgan.
Replaces ~100 hard-coded HTTP status numbers across controllers, services, and middleware with named constants (StatusCodes.OK, .CREATED, .NOT_FOUND, .UNAUTHORIZED, .FORBIDDEN, .CONFLICT, .BAD_REQUEST, .TOO_MANY_REQUESTS, .INTERNAL_SERVER_ERROR). Self-documenting; harder to typo a status number. Touched patterns only: - new ApiError(NNN, ...) - new ApiResponse(NNN, ...) - res.status(NNN) - statusCode = NNN assignments in error.middleware Non-status numeric literals (bcrypt rounds, pagination limits, etc.) left untouched. Server boots clean.
…se/feed Adds 18 integration tests across three suites: - tests/auth.test.js (8 tests): register, duplicate-email rejection, login by email, login by username, bad-password rejection, /users/me with and without token, logout invalidates session. - tests/expense.test.js (7 tests): addToday deducts balance, edit refunds delta down, edit charges delta up, delete refunds when refund=true, delete preserves balance when refund=false, 404 on unknown id, today list sorted. - tests/feed.test.js (3 tests): pagination total/hasMore, category filter, case-insensitive search. Infrastructure: - vitest.config.js: fileParallelism=false so one MongoMemoryServer instance is shared across files. - tests/setup.js: stubs required env synchronously (config/env.js validates at import time), boots in-memory mongo, connects mongoose, wipes collections between tests. - tests/helpers.js: api(), register(), loginAndGetToken(), authHeader(). Caught one latent bug while writing tests: buildUserAndSession discarded the user-supplied username for local register and generated one from name. Fixed: pass the request username through; only synthesize when missing (Google flow). Scripts: npm test, npm run test:watch.
Auto-generated API docs from existing Zod schemas via
@asteasolutions/zod-to-openapi. 32 operations across 25 paths covering
all 8 modules (Auth, User, Sessions, Expenses, Pocket Money, Lent Money,
Reports, Admin).
Endpoints:
- GET /docs — Swagger UI
- GET /openapi.json — raw spec (consume from the future admin SPA to
auto-generate TypeScript types via openapi-typescript)
Infra:
- shared/openapi/init.js — extends Zod with .openapi(), shares the registry
+ bearerAuth security scheme + apiResponse() envelope helper.
- shared/openapi/build.js — side-effect-imports each module's *.openapi.js
to populate the registry, then generates the document.
- modules/<m>/<m>.openapi.js — one per module, registers paths with
schemas re-used from the existing *.validator.js files. No duplication.
Tests: 18/18 still passing.
…ey is now all-time
Compose Sidebar/SidebarProvider/SidebarInset/SidebarMenu/etc. instead of hand-rolled SideNavbar + SidebarContext. Drops ~130 LOC of width math, outside-click handling, and overlay logic. - Tune sidebar CSS vars to match existing theme (white/slate-700 light, #171829/white dark, emerald active/ring). - Pin SidebarInset bg to bg_secondary_light/dark so the body bg shows through like before (shadcn's --background is too dark for our theme). - Keep original sizing: 13rem open / 4.25rem icon-only, h-11 menu items, text-base, size-5 icons, emerald-gradient active state. - Replace SidebarTrigger PanelLeft with the previous ri-menu-line button. - Animate Budgetter logo text fade/slide instead of pop in/out. - Fix TopHeader h-full → h-16 (was stretching to full viewport inside the new flex SidebarInset parent).
- Theme: single source of truth for body.dark in MainLayout (route-aware, landing route stays light). ThemeContext only owns state + storage. - Theme toggle: swap custom hover-expand button for magicui-style View Transitions clip-path animation, wired to existing useTheme(). - Sidebar collapsed mode: hide menu/logout labels and collapse logo gap so icons sit dead-center in the icon column. - Sonner: position bottom-right; add toast.promise on logout. - FormField: vertically center trailing icon (password eye alignment). - Server: bump authLimiter from 10 to 30 failed attempts / 15 min.
Swap the patchy joyride product tour for @sjmc11/tourguidejs and split the feature into a proper module under features/tour/: - tourIds.ts: single registry of target dom ids - tourSteps.ts: pure data, per route, no JSX - TourProvider.tsx: module-level TourGuideClient singleton (the lib uses global element ids, so a second instance silently breaks rendering — singleton survives Strict Mode's double-mount) - runtime filter drops steps whose target is missing or hidden so display:none / `hidden lg:*` elements never produce empty popups Restore the previously broken/commented steps by adding ids to the shadcn Sidebar, the logout button, the membership info block, the add expense / pocket / lent forms, and the pocket money + lent money tables. Drop the addNewExpense and spinWheel steps from data. Move the first-login auto-trigger out of TopHeader (where it didn't belong) into TourProvider; HeaderActions calls useTour().start() directly so the onStartTour prop chain is gone. Bundle drops ~60KB after dropping react-joyride and its 16 transitive deps.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The src/index.js entrypoint no longer exists after the TypeScript port; the sanity step was failing on the v2 branch. Replace with the actual quality gates we now have: tsc --noEmit, tsc build, vitest.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Multi-month v2 cutover bundling the server's TypeScript port, the
MongoDB schema migration to v2, and a client-side UX overhaul.
Server
type: "module"), build todist/, strict mode, modularshared/+modules/layoutPocketMoneyHistory[],LentMoneyHistory[],activeSessions[]arrays onUserextractedinto their own collections;
currentPocketMoneyretyped String → Number;expense day-docs flattened to one doc per line item; sessions hashed
via sha256. Idempotent
migrate-v2-{preflight,users,expenses,verify}scripts; already run successfully against prod Atlas
/api/docsauth / expense / feed
$matchaggregations (dashboard wassilently returning zeros); lent money now sums all unreceived rather
than month-filtered; legacy expense category remap (
Utilities→Housing & Utilitiesetc.)Client
<Sidebar>primitives — drops ~130 LOCof hand-rolled width math, gets Cmd+B shortcut, cookie persistence,
mobile Sheet, proper a11y
(magicui-style); ThemeContext is now state-only, body class
application is route-aware in
MainLayout(landing route islight-only)
react-joyride→@sjmc11/tourguidejs— newfeatures/tour/module with single registry of target ids, route-aware steps,
runtime visibility filter that skips missing/hidden targets, dark
theme overrides
react-hot-toast→ shadcnsonnerat bottom-righttoLocaleStringeverywhere;reports filters default to current month/year; single source of truth
for expense categories
Bundle / deps
react-joyride(−16 transitive packages)react-hot-toastremoved+ @sjmc11/tourguidejs,+ sonner,+ shadcn sidebar depsMigration status
Prod Atlas (
/budgetter) is already on v2 schema — verified2026-05-27, 32/34 users with exact balance match, 2 users with
pre-existing drift (not migration-induced).
Test plan
expensescollection (not embedded)pocketmoneysnpm run buildand starts fromdist/index.js