diff --git a/CHANGELOG.md b/CHANGELOG.md index dba81a3..15e2913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to Bucketwise Planner will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.4] - 2026-04-08 + +### Fixed + +- Profile page: salary and fixed expenses no longer revert on every keypress โ€” `useEffect` dependency `[form]` replaced with `[]` to prevent Mantine `useForm` reference churn from triggering server re-fetches that overwrite in-progress edits (closes #24) +- Profile data isolation: `budget_profiles` now uses `user_id` as the primary key so each user's profile is always written to and read from their own row; previously all users shared `id='profile'`, causing multi-user data corruption + +### Migration Notes + +1. Migration `004-budget-profiles-user-id-primary-key.sql` runs automatically on backend startup +2. The migration deletes any orphaned rows (rows without a matching `users` record), promotes `user_id` to `PRIMARY KEY`, and drops the old `id TEXT` column โ€” **back up your database before upgrading** +3. Existing profile data for authenticated users is preserved as long as their `user_id` FK reference is intact + ## [0.4.3] - 2026-02-12 ### Added @@ -218,10 +231,12 @@ Learn more: https://www.barefootinvestor.com/ --- -[0.3.0]: https://github.com/PaulAtkins88/bucketwise-planner/releases/tag/v0.3.0 -[0.3.1]: https://github.com/PaulAtkins88/bucketwise-planner/releases/tag/v0.3.1 -[0.4.0]: https://github.com/PaulAtkins88/bucketwise-planner/releases/tag/v0.4.0 -[0.4.1]: https://github.com/PaulAtkins88/bucketwise-planner/releases/tag/v0.4.1 -[0.4.2]: https://github.com/PaulAtkins88/bucketwise-planner/releases/tag/v0.4.2 -[0.2.0]: https://github.com/PaulAtkins88/bucketwise-planner/releases/tag/v0.2.0 -[0.1.0]: https://github.com/PaulAtkins88/bucketwise-planner/releases/tag/v0.1.0 +[0.4.4]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.4.4 +[0.4.3]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.4.3 +[0.4.2]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.4.2 +[0.4.1]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.4.1 +[0.4.0]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.4.0 +[0.3.1]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.3.1 +[0.3.0]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.3.0 +[0.2.0]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.2.0 +[0.1.0]: https://github.com/solid-logic-studios/bucketwise-planner/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 53a6ce4..7be4a32 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Bucketwise Planner [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Tests](https://img.shields.io/badge/tests-54%20passing-brightgreen)](https://github.com/PaulAtkins88/bucketwise-planner) +[![Tests](https://img.shields.io/badge/tests-99%20passing-brightgreen)](https://github.com/solid-logic-studios/bucketwise-planner) [![Security](https://img.shields.io/badge/security-SECURITY.md-blue)](SECURITY.md) -[![Release](https://img.shields.io/badge/release-v0.4.2-blue)](https://github.com/PaulAtkins88/bucketwise-planner/releases) +[![Release](https://img.shields.io/badge/release-v0.4.4-blue)](https://github.com/solid-logic-studios/bucketwise-planner/releases) Multi-user budgeting app implementing Scott Pape's Barefoot Investor methodology. Fortnightly budgeting with bucket allocations (60/10/10/20), automated debt snowball payoff, transaction tracking, and optional AI financial advisor. @@ -47,7 +47,7 @@ _This is a community-driven open-source implementation, not affiliated with or e - Infrastructure Layer: PostgreSQL repositories, in-memory adapters - Presentation Layer: HTTP controllers, middleware, routes - **Optional AI**: Google Gen AI SDK (`@google/genai` v1.34.0) with Gemini 2.5 Flash (disabled by default) -- **Testing**: Vitest (54+ passing tests) +- **Testing**: Vitest (99+ passing tests) ### Frontend @@ -154,7 +154,7 @@ volumes: ```bash # Clone and start -git clone https://github.com/PaulAtkins88/bucketwise-planner.git +git clone https://github.com/solid-logic-studios/bucketwise-planner.git cd bucketwise-planner cp .env.example .env # Edit .env with your secrets (JWT_SECRET, ADMIN_SECRET, etc.) @@ -222,6 +222,10 @@ cd backend && pnpm run db:ensure-schema psql "$PG_CONNECTION_STRING" < backend/migrations/003-backfill-fortnight-timezone-bounds.sql # or run the helper script: pnpm --filter backend db:backfill-fortnight-bounds + +# Migration 004 (if upgrading from <= 0.4.3): promotes user_id to primary key in budget_profiles +# This runs automatically on startup โ€” back up your database first if you have profile data +psql "$PG_CONNECTION_STRING" < backend/migrations/004-budget-profiles-user-id-primary-key.sql ``` Linux (apt-based): @@ -252,6 +256,10 @@ cd backend && pnpm run db:ensure-schema psql "$PG_CONNECTION_STRING" < backend/migrations/003-backfill-fortnight-timezone-bounds.sql # or run the helper script: pnpm --filter backend db:backfill-fortnight-bounds + +# Migration 004 (if upgrading from <= 0.4.3): promotes user_id to primary key in budget_profiles +# This runs automatically on startup โ€” back up your database first if you have profile data +psql "$PG_CONNECTION_STRING" < backend/migrations/004-budget-profiles-user-id-primary-key.sql ``` Notes: @@ -303,7 +311,7 @@ Notes: ## ๐Ÿงช Testing -- **54+ passing tests** (Vitest framework) +- **99+ passing tests** (Vitest framework) - **Unit tests:** Domain entities, value objects, use cases - **Integration tests:** API endpoints with test database - **Coverage:** Maintained >80% for critical paths @@ -432,8 +440,8 @@ MIT License โ€” Free for personal and commercial use. See [LICENSE](LICENSE) for ## ๐Ÿ†˜ Support & Community - ๐Ÿ“– **Documentation:** [docs/](docs/), [README files](backend/README.md) -- ๐Ÿ’ฌ **Questions:** [GitHub Discussions](https://github.com/PaulAtkins88/bucketwise-planner/discussions) -- ๐Ÿ› **Bug Reports:** [GitHub Issues](https://github.com/PaulAtkins88/bucketwise-planner/issues) +- ๐Ÿ’ฌ **Questions:** [GitHub Discussions](https://github.com/solid-logic-studios/bucketwise-planner/discussions) +- ๐Ÿ› **Bug Reports:** [GitHub Issues](https://github.com/solid-logic-studios/bucketwise-planner/issues) - ๐Ÿ†˜ **Help:** [SUPPORT.md](SUPPORT.md) and [docs/FAQ.md](docs/FAQ.md) ## ๐ŸŒŸ Roadmap diff --git a/backend/README.md b/backend/README.md index ac17715..456dcc4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ -# Bucketwise Planner Backend +# Bucketwise Planner โ€” Backend -A **Domain-Driven Design** backend implementing the Barefoot Investor bucket-based budgeting methodology. Clean separation of concerns, swappable persistence layers, and optional AI integration. +A **Domain-Driven Design** backend implementing Scott Pape's Barefoot Investor bucket-based budgeting methodology. Clean layered architecture, multi-user JWT authentication, PostgreSQL persistence, and optional AI integration. **Attribution:** Implements Scott Pape's _Barefoot Investor_ methodology ([www.barefootinvestor.com](https://www.barefootinvestor.com/)) @@ -9,10 +9,10 @@ A **Domain-Driven Design** backend implementing the Barefoot Investor bucket-bas This backend supports **multi-user JWT-based authentication**: - User signup/login endpoints -- JWT token generation and validation -- Refresh token support +- JWT access + refresh token flow - Password hashing via bcryptjs -- Each self-hosted instance has its own user database +- Each self-hosted instance has its own isolated user database +- All data (profiles, fortnights, transactions, debts) is scoped to the authenticated user No centralized authentication โ€” each deployment is independent. @@ -21,33 +21,35 @@ No centralized authentication โ€” each deployment is independent. The AI chat feature is **optional and disabled by default**: - Requires Google AI Studio API key (free tier available) -- Disabled if `GEMINI_API_KEY` is not set -- Can be toggled via `AI_ENABLED` environment variable -- No third-party dependency required for core functionality +- Routes only registered if `AI_ENABLED=true` and `GEMINI_API_KEY` is set +- Core budgeting features work fully without the AI key +- Frontend shows a friendly "AI disabled" message when not configured **Get API key:** [https://aistudio.google.com/](https://aistudio.google.com/) +See [docs/AI_ADVISOR.md](../docs/AI_ADVISOR.md) for full setup details. + ## Architecture ``` src/ โ”œโ”€โ”€ domain/ # Pure business logic (no framework deps) -โ”‚ โ”œโ”€โ”€ model/ # Entities & value objects (Money, Debt, Fortnight, etc.) +โ”‚ โ”œโ”€โ”€ model/ # Entities & value objects (Money, Debt, Fortnight, BudgetProfile, etc.) โ”‚ โ”œโ”€โ”€ repositories/ # Interfaces (implementations in infrastructure/) โ”‚ โ”œโ”€โ”€ exceptions/ # Domain errors (ValidationError, DomainError) -โ”‚ โ”œโ”€โ”€ services/ # Business logic (debt calc, payoff projections) -โ”‚ โ””โ”€โ”€ value-objects/ # Money (cents), bucket types, etc. +โ”‚ โ””โ”€โ”€ services/ # Business logic (DebtPayoffCalculator, TimezoneService) โ”œโ”€โ”€ application/ # Use cases & DTOs (orchestration layer) โ”‚ โ”œโ”€โ”€ use-cases/ # IUseCase implementations -โ”‚ โ”œโ”€โ”€ dtos/ # Request/response schemas (Zod validated) -โ”‚ โ””โ”€โ”€ errors/ # Application-level error mapping +โ”‚ โ””โ”€โ”€ dtos/ # Request/response schemas (Zod validated) โ”œโ”€โ”€ infrastructure/ # External concerns -โ”‚ โ”œโ”€โ”€ persistence/ # PostgreSQL repositories -โ”‚ โ”œโ”€โ”€ database/ # Connection, migrations -โ”‚ โ”œโ”€โ”€ auth/ # JWT, bcryptjs integration +โ”‚ โ”œโ”€โ”€ persistence/ +โ”‚ โ”‚ โ”œโ”€โ”€ postgres/ # PostgreSQL repository implementations +โ”‚ โ”‚ โ””โ”€โ”€ memory/ # In-memory implementations (used in tests, STORAGE_METHOD=memory) +โ”‚ โ”œโ”€โ”€ database/ # Connection pool, schema init, migration runner +โ”‚ โ”œโ”€โ”€ auth/ # JWT generation/validation, token blacklist โ”‚ โ””โ”€โ”€ ai/ # Google Gemini integration (optional) โ””โ”€โ”€ presentation/ # HTTP layer - โ””โ”€โ”€ http/ # Express routes, controllers, middleware + โ””โ”€โ”€ http/ # Express v5 routes, controllers, middleware ``` ## Local Development Setup @@ -92,47 +94,59 @@ Then update `.env` with connection string: PG_CONNECTION_STRING=postgresql://budgetwise:your-password@localhost:5432/budgetwise ``` -### Build & Run +### Run Schema Initialization -````bash ```bash -pnpm build # Compile TypeScript -pnpm dev # Start dev server (http://localhost:3000) -pnpm exec tsc --noEmit # Type check -```` +cd backend && pnpm run db:ensure-schema +``` -## AI Advisor (Optional) +This creates all tables and applies any outstanding migrations automatically on startup. -The AI financial advisor is powered by Google Gemini 2.5 Flash. To enable: +#### Upgrading from <= 0.4.0 (timezone fortnight backfill) -1. Get free API key from [https://aistudio.google.com/](https://aistudio.google.com/) -2. Set `GEMINI_API_KEY` in `.env` -3. Set `AI_ENABLED=true` -4. Restart server +```bash +psql "$PG_CONNECTION_STRING" < migrations/003-backfill-fortnight-timezone-bounds.sql +# or +pnpm db:backfill-fortnight-bounds +``` + +#### Upgrading from <= 0.4.3 (profile user_id primary key) -If `AI_ENABLED=false` or `GEMINI_API_KEY` is empty, chat routes are disabled and the app starts normally. No errors will occur. +Migration `004-budget-profiles-user-id-primary-key.sql` runs automatically on startup. To run manually: -**Privacy:** Messages are sent to Google Gemini API (third-party). No permanent chat history stored. +```bash +psql "$PG_CONNECTION_STRING" < migrations/004-budget-profiles-user-id-primary-key.sql +``` -See [docs/AI_ADVISOR.md](../docs/AI_ADVISOR.md) for full details. +**Back up your database before upgrading.** + +### Build & Run + +```bash +pnpm dev # Start dev server with tsx (http://localhost:3000) +pnpm build # Compile TypeScript +pnpm exec tsc --noEmit # Type check only +``` ## Testing ```bash -pnpm test # Run all tests -pnpm test:watch # Watch mode -pnpm test:coverage # Coverage report +pnpm test # Run all tests (99 passing) +pnpm test:watch # Watch mode +pnpm test:coverage # Coverage report ``` -- **54+ passing tests** covering domain logic, use cases, and repositories +- **99+ passing tests** โ€” domain logic, use cases, and repositories - **Vitest** framework for fast, deterministic testing -- **Coverage:** >80% for critical paths +- **Unit tests**: domain entities, value objects, use cases (with mock repositories) +- **Integration tests**: full API endpoints with test database +- **Coverage**: >80% for critical paths ## Key Design Principles - **DDD (Domain-Driven Design)**: Domain logic is isolated and framework-agnostic -- **Repository Pattern**: Swap `MemoryTransactionRepository` for Postgres/SQLite without changing domain code -- **Timezone-Aware Date Handling** (v0.2.0+): Fortnight boundaries evaluated in user's local timezone via `TimezoneService`, preventing UTC/local calendar day mismatches +- **Repository Pattern**: Swap `MemoryTransactionRepository` for Postgres without changing domain code +- **Timezone-Aware Date Handling** (v0.2.0+): Fortnight boundaries evaluated in user's local timezone via `TimezoneService` - **SOLID Principles**: - **S**ingle Responsibility: Each class has one reason to change - **O**pen/Closed: Open for extension (new repo implementations), closed for modification @@ -147,134 +161,53 @@ pnpm test:coverage # Coverage report - **Daily Expenses** (60% of income) โ€” Bills, groceries, essentials - **Splurge** (10%) โ€” Guilt-free discretionary spending - **Smile** (10%) โ€” Long-term goals and dreams -- **Fire Extinguisher** (20%) โ€” Debt payoff โ†’ Emergency fund โ†’ Wealth -- **Mojo** (optional) โ€” Additional savings bucket +- **Fire Extinguisher** (20%) โ€” Debt payoff โ†’ Emergency fund โ†’ Wealth building +- **Mojo** / **Grow** โ€” Optional additional savings buckets Percentages are configurable per user profile. ### Key Entities -- `Transaction`: Record of income/expense -- `Allocation`: Budget allocation percentages for a fortnight -- `FortnightSnapshot`: Period summary with allocations & transactions -- `Money`: Value object for currency (cents-based, no float issues) - -## Use Cases (Application Layer) - -### RecordTransactionUseCase - -```typescript -await recordTxUseCase.execute({ - bucket: 'Daily Expenses', - kind: 'expense', - description: 'Groceries', - amountCents: 5000, - occurredAt: new Date(), - tags: ['food'], -}); -``` - -### CreateFortnightUseCase - -```typescript -await createFortnightUseCase.execute({ - periodStartLocalDate: '2026-01-01', - periodEndLocalDate: '2026-01-14', - allocations: [ - { bucket: 'Daily Expenses', percent: 0.6 }, - { bucket: 'Fire Extinguisher', percent: 0.2 }, - { bucket: 'Splurge', percent: 0.1 }, - { bucket: 'Smile', percent: 0.1 }, - ], -}); -``` - -### Backfill Timezone Bounds (Upgrade) +- `Money`: Value object for currency (integer cents โ€” no float issues) +- `BudgetProfile`: User income, Fire Extinguisher %, fixed expenses, timezone +- `FortnightSnapshot`: Budget period with allocations and transaction summaries +- `Debt`: Balance, minimum payment, interest rate, snowball priority +- `Transaction`: Record of income/expense/debt payment with bucket assignment -If upgrading from <= 0.4.0, run the backfill once to populate timezone-aware fortnight bounds: +### Domain Services -```bash -psql "$PG_CONNECTION_STRING" < migrations/003-backfill-fortnight-timezone-bounds.sql -# or -pnpm db:backfill-fortnight-bounds -``` - -## Domain Services - -### DebtPayoffCalculator - -Calculate months to debt freedom using snowball or avalanche methods. - -### SavingsProjector - -Project savings accumulation and analyze spending trends by bucket. +- `DebtPayoffCalculator`: Snowball payoff timeline in fortnights +- `TimezoneService`: DST-aware UTC โ†” local calendar day conversions ## Persistence Layer -### Currently Supported +### Supported Backends -- **Memory** (default for local dev) - all data in-memory, lost on restart +| Method | Config | Notes | +| -------------- | ------------------------- | ----------------------------------- | +| **PostgreSQL** | `STORAGE_METHOD=postgres` | Recommended for production | +| **Memory** | `STORAGE_METHOD=memory` | Data lost on restart; used in tests | -### Future Implementations +All repository implementations conform to the same domain interfaces. The application layer is unaware of which backend is in use. -- PostgreSQL -- SQLite -- File-based (JSON) +## Migrations -### Switching Persistence Backends +Migrations are plain SQL files in `backend/migrations/`, applied alphabetically at startup via the migration runner. Applied migrations are tracked in the `schema_migrations` table. -All repository implementations follow the same interface. To switch from memory to Postgres: - -```typescript -// Before -import { MemoryTransactionRepository } from './infrastructure/persistence/memory/...'; -const txRepo = new MemoryTransactionRepository(); - -// After -import { PostgresTransactionRepository } from './infrastructure/persistence/postgres/...'; -const txRepo = new PostgresTransactionRepository(pgConnection); -``` +| File | Description | +| --------------------------------------------- | --------------------------------------------------------------------------------------- | +| `001-add-timezone-support.sql` | Adds `timezone` column to `budget_profiles`, UTC bound columns to `fortnight_snapshots` | +| `002-add-transfer-support.sql` | Adds `source_bucket`/`destination_bucket` to transactions | +| `003-backfill-fortnight-timezone-bounds.sql` | Backfills existing fortnights with timezone-aware UTC bounds | +| `004-budget-profiles-user-id-primary-key.sql` | Promotes `user_id` to primary key in `budget_profiles`, drops legacy `id` column | -No changes needed in application layer or domain logic. +## File Naming Conventions -## Testing Strategy - -- **Unit Tests** (`tests/unit/`) - Test isolated domain/application logic -- **Integration Tests** (`tests/integration/`) - Test repositories & use cases together -- **E2E Tests** (`tests/e2e/`) - Test complete workflows - -```bash -# Example: run all tests -pnpm test -``` - -## Next Steps - -1. **Implement HTTP API** (Express/Fastify) - wire controllers to routes -2. **Add Tests** - start with use-case tests -3. **CLI Tool** - add commands for quick testing -4. **Real Persistence** - implement Postgres/SQLite repository -5. **Frontend** - React SPA or other UI consuming the API - -## File Structure Guidelines - -- **kebab-case** for files & folders: `fortnight-snapshot.entity.ts`, `domain/repositories/` +- **kebab-case** for files and folders: `fortnight-snapshot.entity.ts`, `domain/repositories/` - **PascalCase** for classes: `FortnightSnapshot`, `BaseEntity` - **camelCase** for methods/properties: `totalIncome()`, `bucketSpend()` -- Use `.entity.ts`, `.value-object.ts`, `.repository.interface.ts` suffixes for clarity - -## Exports - -All public APIs are exported from `src/index.ts` for easy importing: - -```typescript -import { - FortnightSnapshot, - RecordTransactionUseCase, - MemoryTransactionRepository, -} from './index.js'; -``` +- Suffixes for clarity: `.entity.ts`, `.value-object.ts`, `.repository.interface.ts`, `.use-case.ts` --- -Happy budgeting! ๐Ÿ’ฐ +Happy budgeting! diff --git a/backend/migrations/004-budget-profiles-user-id-primary-key.sql b/backend/migrations/004-budget-profiles-user-id-primary-key.sql new file mode 100644 index 0000000..d94cf17 --- /dev/null +++ b/backend/migrations/004-budget-profiles-user-id-primary-key.sql @@ -0,0 +1,23 @@ +-- Migration 004: Make user_id the unique conflict key for budget_profiles +-- +-- The budget_profiles table previously used a hardcoded id TEXT = 'profile' as its +-- primary key, which meant all users shared the same conflict key on upsert. This is +-- a data-integrity bug in a multi-user system. This migration: +-- 1. Ensures user_id is NOT NULL (it always should have been) +-- 2. Drops the old meaningless `id` TEXT primary key +-- 3. Adds user_id as the new primary key, which correctly scopes each profile to one user + +-- Step 1: Ensure all existing rows have a user_id (clean up any orphaned rows first) +DELETE FROM budget_profiles WHERE user_id IS NULL; + +-- Step 2: Set user_id NOT NULL +ALTER TABLE budget_profiles ALTER COLUMN user_id SET NOT NULL; + +-- Step 3: Drop the old TEXT primary key constraint +ALTER TABLE budget_profiles DROP CONSTRAINT IF EXISTS budget_profiles_pkey; + +-- Step 4: Drop the legacy id column (it was always the hardcoded string 'profile') +ALTER TABLE budget_profiles DROP COLUMN IF EXISTS id; + +-- Step 5: Make user_id the primary key (enforces uniqueness and replaces old PK) +ALTER TABLE budget_profiles ADD PRIMARY KEY (user_id); diff --git a/backend/package.json b/backend/package.json index eebeb16..a26ba1b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,7 +1,7 @@ { "name": "backend", "private": true, - "version": "1.0.3", + "version": "1.0.4", "type": "module", "scripts": { "dev": "tsx src/server.ts", diff --git a/backend/src/application/use-cases/upsert-profile.use-case.ts b/backend/src/application/use-cases/upsert-profile.use-case.ts index 451a022..0e01ffe 100644 --- a/backend/src/application/use-cases/upsert-profile.use-case.ts +++ b/backend/src/application/use-cases/upsert-profile.use-case.ts @@ -34,11 +34,11 @@ export class UpsertProfileUseCase extends UseCase { ON debts (debt_type); CREATE TABLE IF NOT EXISTS budget_profiles ( - id TEXT PRIMARY KEY, + user_id UUID PRIMARY KEY REFERENCES users(id), fortnightly_income_cents INTEGER NOT NULL DEFAULT 0, default_fire_extinguisher_cents INTEGER NOT NULL DEFAULT 0, default_fire_extinguisher_bps INTEGER NOT NULL DEFAULT 0, @@ -130,7 +130,6 @@ export async function ensureSchema(pool: Pool): Promise { ALTER TABLE transactions ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id); ALTER TABLE fortnight_snapshots ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id); ALTER TABLE debts ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id); - ALTER TABLE budget_profiles ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id); ALTER TABLE skipped_debt_payments ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id); ALTER TABLE debt_balance_adjustments ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id); @@ -138,7 +137,6 @@ export async function ensureSchema(pool: Pool): Promise { CREATE INDEX IF NOT EXISTS idx_transactions_user_id ON transactions(user_id); CREATE INDEX IF NOT EXISTS idx_fortnight_snapshots_user_id ON fortnight_snapshots(user_id); CREATE INDEX IF NOT EXISTS idx_debts_user_id ON debts(user_id); - CREATE INDEX IF NOT EXISTS idx_budget_profiles_user_id ON budget_profiles(user_id); CREATE INDEX IF NOT EXISTS idx_skipped_debt_payments_user_id ON skipped_debt_payments(user_id); CREATE INDEX IF NOT EXISTS idx_debt_balance_adjustments_user_id ON debt_balance_adjustments(user_id); CREATE INDEX IF NOT EXISTS idx_debt_balance_adjustments_user_debt_date diff --git a/backend/src/infrastructure/persistence/memory/memory-budget-profile.repository.ts b/backend/src/infrastructure/persistence/memory/memory-budget-profile.repository.ts index 32961b8..accf770 100644 --- a/backend/src/infrastructure/persistence/memory/memory-budget-profile.repository.ts +++ b/backend/src/infrastructure/persistence/memory/memory-budget-profile.repository.ts @@ -1,10 +1,7 @@ -import { randomUUID } from 'crypto'; import { BudgetProfile } from '../../../domain/model/budget-profile.entity.js'; import { Money } from '../../../domain/model/money.js'; import type { BudgetProfileRepository } from '../../../domain/repositories/budget-profile.repository.interface.js'; -const DEFAULT_ID = 'profile'; - export class MemoryBudgetProfileRepository implements BudgetProfileRepository { private profiles: Map = new Map(); @@ -16,18 +13,17 @@ export class MemoryBudgetProfileRepository implements BudgetProfileRepository { this.profiles.set( userId, new BudgetProfile( - profile.id || DEFAULT_ID, + userId, new Money(profile.fortnightlyIncome.cents), profile.defaultFireExtinguisherBps, profile.fixedExpenses.map((fx) => ({ ...fx, - id: fx.id || randomUUID(), amount: new Money(fx.amount.cents), })), profile.timezone || 'UTC', profile.createdAt, - new Date() - ) + new Date(), + ), ); } } diff --git a/backend/src/infrastructure/persistence/postgres/postgres-budget-profile.repository.ts b/backend/src/infrastructure/persistence/postgres/postgres-budget-profile.repository.ts index 37b4a98..4cdc776 100644 --- a/backend/src/infrastructure/persistence/postgres/postgres-budget-profile.repository.ts +++ b/backend/src/infrastructure/persistence/postgres/postgres-budget-profile.repository.ts @@ -5,10 +5,8 @@ import { BudgetProfile, type FixedExpense } from '../../../domain/model/budget-p import { Money } from '../../../domain/model/money.js'; import type { BudgetProfileRepository } from '../../../domain/repositories/budget-profile.repository.interface.js'; -const PROFILE_ID = 'profile'; - type ProfileRow = { - id: string; + user_id: string; fortnightly_income_cents: number; default_fire_extinguisher_cents?: number; default_fire_extinguisher_bps?: number; @@ -23,15 +21,13 @@ type ProfileRow = { updated_at: string; }; -export class PostgresBudgetProfileRepository - implements BudgetProfileRepository -{ +export class PostgresBudgetProfileRepository implements BudgetProfileRepository { constructor(private readonly pool: Pool) {} async getProfile(userId: string): Promise { const result = await this.pool.query( 'SELECT * FROM budget_profiles WHERE user_id = $1 LIMIT 1', - [userId] + [userId], ); if (result.rowCount === 0 || !result.rows[0]) { return null; @@ -49,9 +45,9 @@ export class PostgresBudgetProfileRepository const query = ` INSERT INTO budget_profiles ( - id, user_id, fortnightly_income_cents, default_fire_extinguisher_cents, default_fire_extinguisher_bps, fixed_expenses, timezone, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) - ON CONFLICT (id) + user_id, fortnightly_income_cents, default_fire_extinguisher_cents, default_fire_extinguisher_bps, fixed_expenses, timezone, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (user_id) DO UPDATE SET fortnightly_income_cents = EXCLUDED.fortnightly_income_cents, default_fire_extinguisher_cents = EXCLUDED.default_fire_extinguisher_cents, @@ -62,7 +58,6 @@ export class PostgresBudgetProfileRepository `; await this.pool.query(query, [ - PROFILE_ID, userId, profile.fortnightlyIncome.cents, profile.defaultFireExtinguisherAmount.cents, @@ -81,18 +76,21 @@ export class PostgresBudgetProfileRepository })); return new BudgetProfile( - row.id, + row.user_id, new Money(Number(row.fortnightly_income_cents)), this.resolveBps(row), fixedExpenses, row.timezone || 'UTC', new Date(row.created_at), - new Date(row.updated_at) + new Date(row.updated_at), ); } private resolveBps(row: ProfileRow): number { - if (row.default_fire_extinguisher_bps !== undefined && row.default_fire_extinguisher_bps !== null) { + if ( + row.default_fire_extinguisher_bps !== undefined && + row.default_fire_extinguisher_bps !== null + ) { return Number(row.default_fire_extinguisher_bps); } diff --git a/backend/src/presentation/http/controllers/profile.controller.ts b/backend/src/presentation/http/controllers/profile.controller.ts index aa8c06e..61019cd 100644 --- a/backend/src/presentation/http/controllers/profile.controller.ts +++ b/backend/src/presentation/http/controllers/profile.controller.ts @@ -3,9 +3,11 @@ import multer from 'multer'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { z } from 'zod'; -import { upsertProfileSchema } from '../../../application/dtos/schemas/profile.schema.js'; import { GetProfileUseCase } from '../../../application/use-cases/get-profile.use-case.js'; -import { UpsertProfileUseCase } from '../../../application/use-cases/upsert-profile.use-case.js'; +import { + UpsertProfileUseCase, + type UpsertProfileInput, +} from '../../../application/use-cases/upsert-profile.use-case.js'; import { User } from '../../../domain/model/user.entity.js'; import type { UserRepository } from '../../../domain/repositories/user.repository.interface.js'; import type { AuthenticatedRequest } from '../types/authenticated-request.js'; @@ -15,7 +17,7 @@ export class ProfileController extends BaseController { constructor( private readonly getProfileUseCase: GetProfileUseCase, private readonly upsertProfileUseCase: UpsertProfileUseCase, - private readonly userRepo: UserRepository + private readonly userRepo: UserRepository, ) { super(); } @@ -28,18 +30,19 @@ export class ProfileController extends BaseController { async updateProfile(req: Request, res: Response): Promise { const userId = (req as AuthenticatedRequest).user.id; - const validated = upsertProfileSchema.parse(req.body); + // req.body has already been validated and defaulted by validationMiddleware(upsertProfileSchema) + const body = req.body as Omit; const result = await this.upsertProfileUseCase.execute({ userId, - fortnightlyIncomeCents: validated.fortnightlyIncomeCents, - defaultFireExtinguisherPercent: validated.defaultFireExtinguisherPercent, - fixedExpenses: validated.fixedExpenses.map(fx => ({ + fortnightlyIncomeCents: body.fortnightlyIncomeCents, + defaultFireExtinguisherPercent: body.defaultFireExtinguisherPercent, + fixedExpenses: body.fixedExpenses.map((fx) => ({ id: fx.id, name: fx.name, bucket: fx.bucket, amountCents: fx.amountCents, })), - timezone: validated.timezone, + timezone: body.timezone ?? 'UTC', }); this.sendSuccess(res, result); } @@ -47,7 +50,7 @@ export class ProfileController extends BaseController { async getAvatar(req: Request, res: Response): Promise { const userId = (req as AuthenticatedRequest).user.id; const avatarDir = path.resolve(process.cwd(), 'uploads', 'avatars'); - + // Check for any image extension const extensions = ['.jpg', '.jpeg', '.png']; for (const ext of extensions) { @@ -58,7 +61,7 @@ export class ProfileController extends BaseController { return; } } - + this.sendSuccess(res, { url: null }); } @@ -125,7 +128,7 @@ export class ProfileController extends BaseController { const userId = (req as AuthenticatedRequest).user.id; const updateSchema = z.object({ name: z.string().min(1).max(256) }); const validated = updateSchema.parse(req.body); - + const user = await this.userRepo.getUserById(userId); if (!user) { res.status(404).json({ diff --git a/frontend/README.md b/frontend/README.md index 3372e76..8aa747a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -410,73 +410,3 @@ pnpm exec tsc --noEmit - Add tooltips for complex UI - Update help content for new features - Maintain date normalization - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x'; -import reactDom from 'eslint-plugin-react-dom'; - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` diff --git a/frontend/package.json b/frontend/package.json index ace332d..1855a39 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.3", + "version": "0.0.4", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/views/ProfileView.tsx b/frontend/src/views/ProfileView.tsx index c7c25e7..812f462 100644 --- a/frontend/src/views/ProfileView.tsx +++ b/frontend/src/views/ProfileView.tsx @@ -15,7 +15,13 @@ import { } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useHotkeys } from '@mantine/hooks'; -import { IconDeviceFloppy, IconPlus, IconQuestionMark, IconTrash, IconUpload } from '@tabler/icons-react'; +import { + IconDeviceFloppy, + IconPlus, + IconQuestionMark, + IconTrash, + IconUpload, +} from '@tabler/icons-react'; import { useEffect, useState } from 'react'; import { api } from '../api/client.js'; import type { ProfileDTO } from '../api/types.js'; @@ -98,9 +104,7 @@ export function ProfileView() { const [editingName, setEditingName] = useState(false); const [tempName, setTempName] = useState(''); const { openHelp } = useHelp(); - useHotkeys([ - ['mod+/', () => openHelp('profile')], - ]); + useHotkeys([['mod+/', () => openHelp('profile')]]); const form = useForm({ initialValues: { @@ -111,7 +115,8 @@ export function ProfileView() { }, validate: { fortnightlyIncomeDollars: (value) => (value < 0 ? 'Income must be zero or greater' : null), - defaultFireExtinguisherPercent: (value) => (value < 0 || value > 100 ? 'Percent must be between 0 and 100' : null), + defaultFireExtinguisherPercent: (value) => + value < 0 || value > 100 ? 'Percent must be between 0 and 100' : null, timezone: (value) => (!value ? 'Timezone is required' : null), fixedExpenses: { name: (value) => (!value.trim() ? 'Name is required' : null), @@ -159,9 +164,10 @@ export function ProfileView() { }); return () => { - cancelled = true; + cancelled = true; }; - }, [form]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleAddFixedExpense = () => { form.insertListItem('fixedExpenses', { @@ -237,7 +243,7 @@ export function ProfileView() { } }, 'image/jpeg', - 0.8 + 0.8, ); }; img.onerror = () => reject(new Error('Failed to load image')); @@ -280,7 +286,9 @@ export function ProfileView() { } const computedFireExtinguisherCents = Math.floor( - Math.max(form.values.fortnightlyIncomeDollars, 0) * 100 * (Math.max(form.values.defaultFireExtinguisherPercent, 0) / 100) + Math.max(form.values.fortnightlyIncomeDollars, 0) * + 100 * + (Math.max(form.values.defaultFireExtinguisherPercent, 0) / 100), ); return ( @@ -294,7 +302,11 @@ export function ProfileView() { - @@ -312,8 +324,17 @@ export function ProfileView() { {!editingName ? ( - {userName || 'User'} - @@ -325,11 +346,17 @@ export function ProfileView() { placeholder="Your name" style={{ flex: 1 }} /> - - + + )} - {userEmail} + + {userEmail} + - @@ -351,125 +383,129 @@ export function ProfileView() { Budget Configuration -
- - - - - - - โ‰ˆ {formatCurrency(computedFireExtinguisherCents)} per fortnight at this percent - + + + + + + + + โ‰ˆ {formatCurrency(computedFireExtinguisherCents)} per fortnight at this percent + {/* TODO: This should not be hardcoded, should pull from a source or static file */} - - {form.values.fixedExpenses.length === 0 && ( - - No fixed expenses yet. Add your recurring bills to see them here. - - )} + + + Fixed Expenses + + - {form.values.fixedExpenses.map((expense, index) => ( - - - - Expense {index + 1} - handleRemoveFixedExpense(index)} - aria-label="Remove expense" - > - - - - - - - - ({ value: bucket, label: bucket }))} + {...form.getInputProps(`fixedExpenses.${index}.bucket`)} + /> + + + + + + ))} + + + + + - - - - - -
+
diff --git a/package.json b/package.json index 93360e6..c46c55b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "budget_app", "private": true, - "version": "0.4.3", + "version": "0.4.4", "description": "Workspace root for backend and frontend packages", "packageManager": "pnpm@10.25.0", "scripts": { diff --git a/tsconfig.json b/tsconfig.json index 7f01594..973d041 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,16 +17,12 @@ "noUncheckedSideEffectImports": true, "skipLibCheck": true, "resolveJsonModule": true, - "esModuleInterop": false, "allowImportingTsExtensions": false, "noEmit": false, "sourceMap": true, "declaration": true, "declarationMap": true }, - "include": [ - "backend/src/**/*.ts", - "backend/tests/**/*.ts" - ], + "include": ["backend/src/**/*.ts", "backend/tests/**/*.ts"], "exclude": ["node_modules", "dist", "frontend"] -} \ No newline at end of file +}