From 087fbdd1769415ba4768e4c8e71ae75a7d36a906 Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 2 May 2026 16:30:31 -0500 Subject: [PATCH] Split into Budget.Core / Budget.Infrastructure / Budget.Api projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budget.Core: entities, DTOs, enums, FrequencyCalculator (no EF/ASP.NET deps) Budget.Infrastructure: AppDbContext, migrations, BudgetAuthorizationService Budget.Api: controllers, middleware, Program.cs — references both projects EF and Npgsql packages moved to Infrastructure; Api retains only JwtBearer, HealthChecks, and EF.Design (needed for dotnet ef CLI). Dockerfile updated to copy all three project directories before publishing. Migration namespaces updated from Budget.Api.Data.* to Budget.Infrastructure.Data.* and model type strings updated to Budget.Core.Models.* in the snapshot. Co-Authored-By: Claude Sonnet 4.6 --- .plans/10-project-split.md | 90 +++++++++++ .plans/11-react-oidc-context.md | 86 +++++++++++ .plans/12-tanstack-query.md | 123 +++++++++++++++ .plans/13-react-hook-form-zod.md | 126 +++++++++++++++ .plans/18-soft-delete-concurrency.md | 106 +++++++++++++ .plans/19-pagination.md | 145 ++++++++++++++++++ Budget.sln | 30 ++++ Dockerfile | 2 + src/Budget.Api/Budget.Api.csproj | 7 +- .../Controllers/BudgetsController.cs | 10 +- .../Controllers/IncomesController.cs | 8 +- .../Controllers/OutgosController.cs | 8 +- .../Controllers/SharesController.cs | 7 +- .../Controllers/SummaryController.cs | 8 +- src/Budget.Api/Program.cs | 3 +- .../Services/KnownUserMiddleware.cs | 4 +- src/Budget.Core/Budget.Core.csproj | 9 ++ .../DTOs/BudgetDtos.cs | 2 +- .../DTOs/IncomeDtos.cs | 4 +- .../DTOs/OutgoDtos.cs | 4 +- .../DTOs/ShareDtos.cs | 4 +- .../DTOs/SummaryDtos.cs | 2 +- .../Models/Budget.cs | 2 +- .../Models/BudgetShare.cs | 2 +- .../Models/Frequency.cs | 2 +- .../Models/Income.cs | 2 +- .../Models/KnownUser.cs | 2 +- .../Models/Outgo.cs | 2 +- .../Models/OutgoType.cs | 2 +- .../Models/SharePermission.cs | 2 +- .../Services/FrequencyCalculator.cs | 4 +- .../Budget.Infrastructure.csproj | 18 +++ .../Data/AppDbContext.cs | 8 +- .../20260425123657_InitialCreate.Designer.cs | 30 ++-- .../20260425123657_InitialCreate.cs | 4 +- .../Migrations/AppDbContextModelSnapshot.cs | 30 ++-- .../Services/BudgetAuthorizationService.cs | 6 +- 37 files changed, 826 insertions(+), 78 deletions(-) create mode 100644 .plans/10-project-split.md create mode 100644 .plans/11-react-oidc-context.md create mode 100644 .plans/12-tanstack-query.md create mode 100644 .plans/13-react-hook-form-zod.md create mode 100644 .plans/18-soft-delete-concurrency.md create mode 100644 .plans/19-pagination.md create mode 100644 src/Budget.Core/Budget.Core.csproj rename src/{Budget.Api => Budget.Core}/DTOs/BudgetDtos.cs (92%) rename src/{Budget.Api => Budget.Core}/DTOs/IncomeDtos.cs (92%) rename src/{Budget.Api => Budget.Core}/DTOs/OutgoDtos.cs (94%) rename src/{Budget.Api => Budget.Core}/DTOs/ShareDtos.cs (86%) rename src/{Budget.Api => Budget.Core}/DTOs/SummaryDtos.cs (95%) rename src/{Budget.Api => Budget.Core}/Models/Budget.cs (94%) rename src/{Budget.Api => Budget.Core}/Models/BudgetShare.cs (93%) rename src/{Budget.Api => Budget.Core}/Models/Frequency.cs (84%) rename src/{Budget.Api => Budget.Core}/Models/Income.cs (91%) rename src/{Budget.Api => Budget.Core}/Models/KnownUser.cs (87%) rename src/{Budget.Api => Budget.Core}/Models/Outgo.cs (94%) rename src/{Budget.Api => Budget.Core}/Models/OutgoType.cs (65%) rename src/{Budget.Api => Budget.Core}/Models/SharePermission.cs (63%) rename src/{Budget.Api => Budget.Core}/Services/FrequencyCalculator.cs (93%) create mode 100644 src/Budget.Infrastructure/Budget.Infrastructure.csproj rename src/{Budget.Api => Budget.Infrastructure}/Data/AppDbContext.cs (91%) rename src/{Budget.Api => Budget.Infrastructure}/Data/Migrations/20260425123657_InitialCreate.Designer.cs (89%) rename src/{Budget.Api => Budget.Infrastructure}/Data/Migrations/20260425123657_InitialCreate.cs (99%) rename src/{Budget.Api => Budget.Infrastructure}/Data/Migrations/AppDbContextModelSnapshot.cs (88%) rename src/{Budget.Api => Budget.Infrastructure}/Services/BudgetAuthorizationService.cs (93%) diff --git a/.plans/10-project-split.md b/.plans/10-project-split.md new file mode 100644 index 0000000..18f6eab --- /dev/null +++ b/.plans/10-project-split.md @@ -0,0 +1,90 @@ +# Plan: Core / Infrastructure / API Project Split + +## Goal + +Split the single `Budget.Api` project into three projects matching the stwaddle stack pattern: +- `Budget.Core` — entities, DTOs, enums. No ASP.NET or EF dependencies. +- `Budget.Infrastructure` — DbContext, EF configs, migrations, domain services. +- `Budget.Api` — controllers, middleware, Program.cs, DI wiring. + +## Current state + +Everything lives in `src/Budget.Api/`: +``` +Models/ Budget, Income, Outgo, BudgetShare, KnownUser, enums +DTOs/ BudgetDtos, IncomeDtos, OutgoDtos, ShareDtos, SummaryDtos +Data/ AppDbContext, Migrations/ +Services/ BudgetAuthorizationService, FrequencyCalculator, + KnownUserMiddleware, ErrorHandlingMiddleware, ClaimsPrincipalExtensions +Controllers/ BudgetsController, IncomesController, OutgosController, + SharesController, SummaryController +Program.cs +``` + +## Target state + +``` +src/ + Budget.Core/ + Models/ Budget, Income, Outgo, BudgetShare, KnownUser, enums + DTOs/ all DTO records and request types + Services/ FrequencyCalculator (pure logic, no EF/ASP.NET) + + Budget.Infrastructure/ + Data/ AppDbContext, Migrations/ + Services/ BudgetAuthorizationService + + Budget.Api/ + Controllers/ + Services/ KnownUserMiddleware, ErrorHandlingMiddleware, + ClaimsPrincipalExtensions + Program.cs +``` + +**References:** `Budget.Api` → `Budget.Infrastructure` → `Budget.Core` + +## Steps + +### Phase 1 — Create projects and move files + +1. `dotnet new classlib -n Budget.Core -o src/Budget.Core --framework net10.0` +2. `dotnet new classlib -n Budget.Infrastructure -o src/Budget.Infrastructure --framework net10.0` +3. Add both to the solution: `dotnet sln add src/Budget.Core/Budget.Core.csproj src/Budget.Infrastructure/Budget.Infrastructure.csproj` +4. Move `Models/` and `DTOs/` to `Budget.Core`; update namespaces from `Budget.Api.*` to `Budget.Core.*`. +5. Move `FrequencyCalculator.cs` to `Budget.Core/Services/`. +6. Move `Data/` (AppDbContext + Migrations) to `Budget.Infrastructure/Data/`. +7. Move `BudgetAuthorizationService.cs` to `Budget.Infrastructure/Services/`. + +### Phase 2 — Wire up project references and NuGet packages + +8. Add project reference: `Budget.Infrastructure` → `Budget.Core`. +9. Move EF Core + Npgsql NuGet packages from `Budget.Api.csproj` to `Budget.Infrastructure.csproj`. Keep only `Microsoft.AspNetCore.Authentication.JwtBearer` and health-check packages in `Budget.Api.csproj`. +10. Add project references to `Budget.Api.csproj`: both `Budget.Core` and `Budget.Infrastructure`. +11. Add `Microsoft.EntityFrameworkCore.Design` to `Budget.Api.csproj` (needed for `dotnet ef` CLI to find the startup project). + +### Phase 3 — Update namespaces and using statements + +12. Global search-replace across all moved files: `namespace Budget.Api` → `namespace Budget.Core` or `namespace Budget.Infrastructure` as appropriate. +13. Update `using` directives in all controllers, middleware, and Program.cs to reference the new namespaces. + +### Phase 4 — Update Dockerfile and EF migrations command + +14. Update Dockerfile Stage 2 to copy and restore all three projects before publishing `Budget.Api`. +15. Verify `dotnet ef migrations add` command still works with `--project Budget.Infrastructure --startup-project Budget.Api`. +16. Build and confirm zero errors. + +## Key decisions + +- `ClaimsPrincipalExtensions` and the two middleware classes stay in `Budget.Api` — they have direct ASP.NET dependencies and are not reusable across projects. +- `BudgetAuthorizationService` goes in `Budget.Infrastructure` because it queries the DbContext. +- `FrequencyCalculator` goes in `Budget.Core` because it is pure arithmetic with no external dependencies. +- DTOs stay in `Budget.Core` (not `Budget.Api`) so `Budget.Infrastructure` can reference them if needed without creating a circular dependency. + +## Files affected + +- `Budget.sln` +- New: `src/Budget.Core/Budget.Core.csproj` +- New: `src/Budget.Infrastructure/Budget.Infrastructure.csproj` +- `src/Budget.Api/Budget.Api.csproj` (trim NuGet packages, add project refs) +- All `.cs` files under `src/Budget.Api/` (namespace updates) +- `Dockerfile` (Stage 2 COPY pattern) diff --git a/.plans/11-react-oidc-context.md b/.plans/11-react-oidc-context.md new file mode 100644 index 0000000..2430b84 --- /dev/null +++ b/.plans/11-react-oidc-context.md @@ -0,0 +1,86 @@ +# Plan: Replace Custom AuthContext with react-oidc-context + +## Goal + +Replace the hand-rolled `AuthContext.tsx` / `UserManager` setup with `react-oidc-context`, +which wraps `oidc-client-ts` and handles token expiry events, silent renew errors, and +session monitoring out of the box. + +## Current state + +- `src/Budget.Client/src/auth/authConfig.ts` — `UserManagerSettings` object +- `src/Budget.Client/src/auth/AuthContext.tsx` — custom Provider + `useAuth()` hook, module-level `UserManager` +- `src/Budget.Client/src/auth/AuthGuard.tsx` — reads from `useAuth()` +- `src/Budget.Client/src/pages/CallbackPage.tsx` — calls `userManager.signinRedirectCallback()` +- `src/Budget.Client/src/api/client.ts` — `setTokenProvider(fn)` wired in `main.tsx` +- `src/Budget.Client/src/main.tsx` — wraps app in `` + +## Target state + +- Remove `AuthContext.tsx`. +- `main.tsx` wraps the app in `` from `react-oidc-context` using the existing `authConfig`. +- `AuthGuard.tsx` uses `useAuth()` from `react-oidc-context`. +- `CallbackPage.tsx` becomes a thin component — `react-oidc-context` handles the callback automatically when `onSigninCallback` is provided to `AuthProvider`. +- A `TokenSync` component reads the token from `react-oidc-context` and pushes it into `api/client.ts` via `setTokenProvider`. +- `onSigninCallback` navigates to `/` on success but skips navigation when the URL contains `error`. +- `CallbackPage` reads `error` / `error_description` from URL params and renders a user-friendly error message. + +## Steps + +### Phase 1 — Install + +1. `npm install react-oidc-context` in `src/Budget.Client/`. + +### Phase 2 — Replace AuthProvider + +2. Delete `src/Budget.Client/src/auth/AuthContext.tsx`. +3. Update `src/Budget.Client/src/main.tsx`: + - Import `AuthProvider` from `react-oidc-context`. + - Add `onSigninCallback` that calls `window.history.replaceState({}, '', '/')` unless the URL has `?error=`. + - Wrap the app: ``. + - Remove the old `` import and `setTokenProvider` wiring. +4. Create `src/Budget.Client/src/auth/TokenSync.tsx`: + ```tsx + import { useAuth } from 'react-oidc-context'; + import { useEffect } from 'react'; + import { setTokenProvider } from '../api/client'; + + export function TokenSync() { + const auth = useAuth(); + useEffect(() => { + setTokenProvider(() => auth.user?.access_token ?? null); + }, [auth.user]); + return null; + } + ``` +5. Render `` inside `` in `main.tsx`. + +### Phase 3 — Update AuthGuard and CallbackPage + +6. Update `AuthGuard.tsx` to use `useAuth` from `react-oidc-context`. The shape is: + - `auth.isLoading` — waiting for user to load + - `auth.isAuthenticated` — user is signed in + - `auth.signinRedirect()` — trigger login +7. Update `CallbackPage.tsx`: + - `react-oidc-context` processes the callback automatically; the page just needs to read `?error` / `?error_description` from `useSearchParams()` and render a friendly message if present. + - On success the `onSigninCallback` in `main.tsx` navigates away, so this component effectively only shows on error. + +### Phase 4 — Clean up + +8. Remove `export { userManager }` from the old AuthContext (now deleted) and fix any imports that referenced it (only `CallbackPage` should have used it). +9. Run `npm run build` — confirm zero TypeScript errors. + +## Key decisions + +- `authConfig.ts` stays as-is; `AuthProvider` accepts `UserManagerSettings` spread directly. +- `setTokenProvider` stays in `api/client.ts` — `TokenSync` bridges between `react-oidc-context` and the fetch client without making the client depend on React. +- Silent renew errors are handled automatically by `react-oidc-context`'s built-in event listeners — no custom wiring needed. + +## Files affected + +- `package.json` (new dep: `react-oidc-context`) +- `src/Budget.Client/src/main.tsx` +- `src/Budget.Client/src/auth/AuthContext.tsx` (deleted) +- `src/Budget.Client/src/auth/AuthGuard.tsx` +- New: `src/Budget.Client/src/auth/TokenSync.tsx` +- `src/Budget.Client/src/pages/CallbackPage.tsx` diff --git a/.plans/12-tanstack-query.md b/.plans/12-tanstack-query.md new file mode 100644 index 0000000..dfd39af --- /dev/null +++ b/.plans/12-tanstack-query.md @@ -0,0 +1,123 @@ +# Plan: Add TanStack React Query + +## Goal + +Replace the ad-hoc `useEffect` + `useState` fetch pattern with `@tanstack/react-query`. +All data fetching moves into typed `useQuery` / `useMutation` hooks. A global `MutationCache` +fires toasts on success and error so pages don't have to wire that up individually. + +**Prerequisite:** Plan #11 (react-oidc-context) must be complete, as `TokenSync` replaces +the `setTokenProvider` wiring that pages currently rely on. + +## Current state + +Each page manages its own loading/error state with `useState` and fetches in `useEffect`: +```ts +const [budgets, setBudgets] = useState([]); +useEffect(() => { + api.get('/api/budgets').then(setBudgets); +}, []); +``` + +Mutations are plain `async` calls with no error handling or cache invalidation. + +## Target state + +``` +src/api/ + client.ts — unchanged fetch wrapper + queryClient.ts — QueryClient with MutationCache for global toasts + budgets.ts — useQuery/useMutation hooks for budgets + incomes.ts — hooks for incomes + outgos.ts — hooks for outgos + shares.ts — hooks for shares + summary.ts — hooks for summary +``` + +Pages become thin: call a hook, render data — no fetch logic inline. + +## Steps + +### Phase 1 — Install and configure QueryClient + +1. `npm install @tanstack/react-query` in `src/Budget.Client/`. +2. Create `src/Budget.Client/src/api/queryClient.ts`: + - Create a `QueryClient` with a `MutationCache` that reads `meta.successMessage` and + `meta.errorMessage` from each mutation and fires the existing `toast` utility on + `onSuccess` / `onError`. + - Export the `queryClient` singleton. +3. Wrap the app in `` in `main.tsx` + (inside ``). + +### Phase 2 — Create domain hook files + +For each domain, create a hooks file following this pattern: + +**`src/api/budgets.ts`** +```ts +export function useBudgets() { + return useQuery({ queryKey: ['budgets'], queryFn: () => api.get('/api/budgets') }); +} + +export function useCreateBudget() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateBudgetRequest) => api.post('/api/budgets', req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets'] }), + meta: { successMessage: 'Budget created', errorMessage: 'Failed to create budget' }, + }); +} + +export function useUpdateBudget(id: string) { ... } +export function useDeleteBudget() { ... } +``` + +4. Create `src/api/budgets.ts` — hooks for List, Create, Update, Delete. +5. Create `src/api/incomes.ts` — hooks for List, Create, Update, Delete, Reorder. + Query key: `['budgets', budgetId, 'incomes']`. +6. Create `src/api/outgos.ts` — hooks for List, Create, Update, Delete, Reorder. + Query key: `['budgets', budgetId, 'outgos']`. + Also hooks for `categories` and `payment-sources` lookups. +7. Create `src/api/shares.ts` — hooks for List, Add, Update, Revoke. + Query key: `['budgets', budgetId, 'shares']`. +8. Create `src/api/summary.ts` — hooks for Get and UpdateTaxRate. + Query key: `['budgets', budgetId, 'summary']`. + +### Phase 3 — Update pages to use hooks + +9. **`BudgetsPage.tsx`** — replace `useEffect` + `useState` with `useBudgets()` and `useCreateBudget()`. +10. **`IncomePage.tsx`** — replace with `useIncomes()`, `useCreateIncome()`, `useUpdateIncome()`, `useDeleteIncome()`, `useReorderIncomes()`. +11. **`OutgoPage.tsx`** — replace with outgo hooks + category/payment-source lookup hooks. +12. **`SummaryPage.tsx`** — replace with `useSummary()` and `useUpdateTaxRate()`. +13. **`SettingsPage.tsx`** — replace with `useShares()`, `useAddShare()`, `useUpdateShare()`, `useRevokeShare()`. + +### Phase 4 — Clean up + +14. Remove `setTokenProvider` from `main.tsx` if it is no longer wired there (it should now live only in `TokenSync.tsx` from plan #11). +15. Remove unused `useEffect` / `useState` imports from pages. +16. `npm run build` — zero TypeScript errors. + +## Key decisions + +- `api/client.ts` is not changed — hooks wrap it, they don't replace it. +- Cache invalidation strategy: mutations invalidate the narrowest relevant query key + (e.g., a deleted income invalidates `['budgets', id, 'incomes']`, not all budgets). +- No optimistic updates in this plan — add separately if needed. +- `meta.successMessage` and `meta.errorMessage` are typed by augmenting the + `@tanstack/react-query` module's `Register` interface so TypeScript validates them. + +## Files affected + +- `package.json` +- `src/Budget.Client/src/main.tsx` +- New: `src/Budget.Client/src/api/queryClient.ts` +- New: `src/Budget.Client/src/api/budgets.ts` +- New: `src/Budget.Client/src/api/incomes.ts` +- New: `src/Budget.Client/src/api/outgos.ts` +- New: `src/Budget.Client/src/api/shares.ts` +- New: `src/Budget.Client/src/api/summary.ts` +- `src/Budget.Client/src/pages/BudgetsPage.tsx` +- `src/Budget.Client/src/pages/IncomePage.tsx` +- `src/Budget.Client/src/pages/OutgoPage.tsx` +- `src/Budget.Client/src/pages/SummaryPage.tsx` +- `src/Budget.Client/src/pages/SettingsPage.tsx` diff --git a/.plans/13-react-hook-form-zod.md b/.plans/13-react-hook-form-zod.md new file mode 100644 index 0000000..dd37325 --- /dev/null +++ b/.plans/13-react-hook-form-zod.md @@ -0,0 +1,126 @@ +# Plan: Add react-hook-form + zod for Form Validation + +## Goal + +Replace ad-hoc form state (`useState` per field, manual validation) with `react-hook-form` +and `zod` schemas, matching the stwaddle stack convention. Validation errors render inline +next to fields. + +**Prerequisite:** Plan #12 (TanStack React Query) must be complete. Mutation hooks from +that plan are used as the submit handlers here — `useForm` `handleSubmit` calls +`mutation.mutateAsync()`. + +## Current state + +Forms are managed ad-hoc: +```tsx +const [newName, setNewName] = useState(''); +const handleCreate = async () => { + if (!newName.trim()) return; + await createMutation.mutateAsync({ name: newName.trim() }); +}; +``` + +No schema validation, no field-level error messages, no loading/disabled states during +submit. + +## Steps + +### Phase 1 — Install + +1. `npm install react-hook-form zod @hookform/resolvers` in `src/Budget.Client/`. + +### Phase 2 — Create zod schemas + +2. Create `src/Budget.Client/src/schemas/index.ts` with one schema per create/update request: + +```ts +export const createBudgetSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), +}); + +export const createIncomeSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + frequency: z.nativeEnum(Frequency), + amount: z.number({ invalid_type_error: 'Amount is required' }).positive('Must be positive'), +}); + +export const createOutgoSchema = z.object({ + name: z.string().min(1).max(200), + category: z.string().max(100).optional(), + type: z.nativeEnum(OutgoType), + frequency: z.nativeEnum(Frequency), + amount: z.number().positive(), + paymentSource: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), +}); + +export const createShareSchema = z.object({ + email: z.string().email('Must be a valid email'), + permission: z.nativeEnum(SharePermission), +}); + +export const updateTaxRateSchema = z.object({ + effectiveTaxRate: z.number().min(0).max(0.99), +}); +``` + +### Phase 3 — Update forms in pages + +For each form, the pattern is: + +```tsx +const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm({ + resolver: zodResolver(createBudgetSchema), +}); + +const onSubmit = async (data: CreateBudgetRequest) => { + await createBudget.mutateAsync(data); + reset(); +}; + +return ( +
+ + {errors.name && {errors.name.message}} + +
+); +``` + +3. **`BudgetsPage.tsx`** — wire `createBudgetSchema` into the budget name input. +4. **`IncomePage.tsx`** — wire `createIncomeSchema` / `updateIncomeSchema` for the add and edit income forms. +5. **`OutgoPage.tsx`** — wire `createOutgoSchema` / `updateOutgoSchema` for add and edit outgo forms (this is the most complex form). +6. **`SettingsPage.tsx`** — wire `createShareSchema` for the invite form; `updateShareSchema` for the permission dropdown. +7. **`SummaryPage.tsx`** — wire `updateTaxRateSchema` for the tax rate field. + +### Phase 4 — Disable submit during mutation + +8. Set `disabled={isSubmitting || mutation.isPending}` on submit buttons. This prevents + double-submits while the network request is in flight. + +### Phase 5 — Build and verify + +9. `npm run build` — zero TypeScript errors. +10. Manually verify that submitting an empty name shows an inline error without hitting the API. + +## Key decisions + +- Schemas live in a single `schemas/index.ts` for now — split into per-domain files if they grow large. +- `amount` fields: HTML `` returns a string; use `z.coerce.number()` or + `valueAsNumber: true` in `register()` to get a proper number before Zod validates. +- Update schemas (for edit forms) reuse the same shape as create schemas where the fields are + identical — they are not duplicated. +- No server-side error mapping in this plan: if the API returns 422/400 with `{ error: "..." }`, + the global MutationCache toast (from plan #12) handles it. Field-level server errors can be + mapped via `setError` in a future pass if needed. + +## Files affected + +- `package.json` +- New: `src/Budget.Client/src/schemas/index.ts` +- `src/Budget.Client/src/pages/BudgetsPage.tsx` +- `src/Budget.Client/src/pages/IncomePage.tsx` +- `src/Budget.Client/src/pages/OutgoPage.tsx` +- `src/Budget.Client/src/pages/SettingsPage.tsx` +- `src/Budget.Client/src/pages/SummaryPage.tsx` diff --git a/.plans/18-soft-delete-concurrency.md b/.plans/18-soft-delete-concurrency.md new file mode 100644 index 0000000..617ac60 --- /dev/null +++ b/.plans/18-soft-delete-concurrency.md @@ -0,0 +1,106 @@ +# Plan: Soft Delete and Concurrency Tokens + +## Goal + +Add soft delete (`IsDeleted` / `DeletedAt` + global EF query filter) and a `xmin`-based +concurrency token (`RowVersion`) to the entities that benefit from them, matching the +stwaddle stack convention. + +## Current state + +- Hard deletes on all entities — `db.X.Remove(entity); await db.SaveChangesAsync()`. +- No concurrency tokens — concurrent updates overwrite each other silently. +- No `IsDeleted` / `DeletedAt` columns. + +## Scope decisions + +**Soft delete:** Apply to `Budget`, `Income`, `Outgo`, and `BudgetShare`. +`KnownUser` is a provisioning-only table; hard deletes are fine there. + +**Concurrency token:** Apply to `Budget` only for now — it is the entity most +likely to be edited by multiple users simultaneously (owner + shared editor). +`Income` and `Outgo` updates are user-local; `BudgetShare` and `KnownUser` are +low-contention. Extend later if needed. + +## Steps + +### Phase 1 — Update entity models + +> If plan #10 (project split) has been implemented, these files live in `Budget.Core/Models/`. +> Otherwise they are in `Budget.Api/Models/`. + +1. Add an `ISoftDeletable` interface (or base class) with `bool IsDeleted`, `DateTimeOffset? DeletedAt`. +2. Implement on `Budget`, `Income`, `Outgo`, `BudgetShare`. +3. Add `byte[] RowVersion` (mapped to `xmin`) to `Budget` only. + +### Phase 2 — Update AppDbContext / EF configuration + +> If plan #10 has been implemented, `AppDbContext` is in `Budget.Infrastructure/Data/`. + +4. In `AppDbContext.OnModelCreating`, for each soft-deletable entity add: + ```csharp + builder.HasQueryFilter(x => !x.IsDeleted); + ``` +5. For `Budget`, add the concurrency token config: + ```csharp + builder.Property(e => e.RowVersion) + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + ``` + +### Phase 3 — Update delete endpoints to soft delete + +6. In each controller, replace `db.X.Remove(entity)` with: + ```csharp + entity.IsDeleted = true; + entity.DeletedAt = DateTimeOffset.UtcNow; + ``` + Affected actions: `BudgetsController.Delete`, `IncomesController.Delete`, + `OutgosController.Delete`, `SharesController.Revoke`. + +### Phase 4 — Handle concurrency conflicts + +7. In `BudgetsController.Update` (and any other Budget write endpoint), wrap + `SaveChangesAsync` in a try/catch for `DbUpdateConcurrencyException` and return + `Conflict(new { error = "The budget was modified by another user. Please refresh and retry." })`. + +### Phase 5 — Migration + +8. `dotnet ef migrations add AddSoftDeleteAndConcurrency --project Budget.Infrastructure --startup-project Budget.Api` + (adjust project flags if plan #10 has not been implemented yet). +9. Review the generated migration — verify `xmin` column is not added as a normal column + (it should appear only in `modelSnapshot`, not as an `AddColumn` since `xmin` is a Postgres + system column). +10. Apply: `dotnet ef database update` or let startup migrations handle it. + +### Phase 6 — Build and verify + +11. `dotnet build` — zero errors. +12. Manually verify that a soft-deleted budget no longer appears in `GET /api/budgets` + without any controller changes (query filter handles it). + +## Key decisions + +- No admin endpoint to view/restore deleted records is in scope here — add `IgnoreQueryFilters()` + to a future admin endpoint if needed. +- `RowVersion` is not exposed in DTOs or returned to the client in this plan. + If optimistic concurrency is needed client-side (e.g., ETag), that is a separate concern. +- Cascade behavior: when a `Budget` is soft-deleted its `Incomes`, `Outgos`, and `BudgetShares` + are not automatically soft-deleted. They are filtered out indirectly because `BudgetsController` + checks budget access before returning child resources. Explicit cascading can be added later. + +## Files affected + +- `Budget.Core/Models/Budget.cs` (or `Budget.Api/Models/Budget.cs`) +- `Budget.Core/Models/Income.cs` +- `Budget.Core/Models/Outgo.cs` +- `Budget.Core/Models/BudgetShare.cs` +- New: `Budget.Core/Models/ISoftDeletable.cs` (optional interface) +- `Budget.Infrastructure/Data/AppDbContext.cs` (or `Budget.Api/Data/AppDbContext.cs`) +- `Budget.Api/Controllers/BudgetsController.cs` +- `Budget.Api/Controllers/IncomesController.cs` +- `Budget.Api/Controllers/OutgosController.cs` +- `Budget.Api/Controllers/SharesController.cs` +- New migration file diff --git a/.plans/19-pagination.md b/.plans/19-pagination.md new file mode 100644 index 0000000..e28796e --- /dev/null +++ b/.plans/19-pagination.md @@ -0,0 +1,145 @@ +# Plan: Add PagedResult Pagination + +## Goal + +Make all list endpoints return `PagedResult` with `page` / `pageSize` query params, +bounded by a config-driven max, matching the stwaddle stack API convention. +Update the frontend to consume paginated responses. + +**Prerequisites:** +- Plan #10 (project split) — DTOs and request types will live in `Budget.Core`. +- Plan #12 (TanStack Query) — query hooks need to be updated to pass page params and + handle paginated responses. Doing this before #12 means rewriting the hooks twice. + +## Scope + +Paginate all collection endpoints: +- `GET /api/budgets` +- `GET /api/budgets/{id}/incomes` +- `GET /api/budgets/{id}/outgos` +- `GET /api/budgets/{id}/shares` + +`GET /api/budgets/{id}/summary` is a single-object endpoint — not paginated. +`GET /api/budgets/{id}/outgos/categories` and `/payment-sources` return small +distinct-value lists — not paginated. + +## Steps + +### Phase 1 — Backend: shared types and config + +> If plan #10 is done, add these to `Budget.Core`. Otherwise add to `Budget.Api/DTOs/`. + +1. Add `PagedResult` record: + ```csharp + public record PagedResult(IReadOnlyList Items, int Page, int PageSize, int TotalCount) + { + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); + } + ``` +2. Add pagination config to `appsettings.json`: + ```json + "Pagination": { + "DefaultPageSize": 25, + "MaxPageSize": 100 + } + ``` +3. Register `IOptions` in Program.cs: + ```csharp + builder.Services.Configure(builder.Configuration.GetSection("Pagination")); + ``` + with a `PaginationOptions` record/class containing `DefaultPageSize` and `MaxPageSize`. + +### Phase 2 — Backend: update list endpoints + +4. Inject `IOptions` into each controller that has a list endpoint, + or pass it via a helper. +5. Add `int page = 1, int pageSize = 0` parameters to each list action. If `pageSize` is + 0 or not provided, use `DefaultPageSize`. Clamp `pageSize` to `MaxPageSize`. +6. Update each list query to use `.Skip((page - 1) * pageSize).Take(pageSize)` and + return `PagedResult`: + ```csharp + var total = await query.CountAsync(); + var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); + return Ok(new PagedResult(items, page, pageSize, total)); + ``` +7. Affected actions: `BudgetsController.List`, `IncomesController.List`, + `OutgosController.List`, `SharesController.List`. + +### Phase 3 — Frontend: update types + +8. Add `PagedResult` to `src/Budget.Client/src/types/index.ts`: + ```ts + export interface PagedResult { + items: T[]; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; + } + ``` + +### Phase 4 — Frontend: update query hooks + +> If plan #12 is done, update the hook files. Otherwise update the `useEffect` fetch calls. + +9. Update each list hook to accept a `page` param (default `1`) and return `PagedResult`: + ```ts + export function useBudgets(page = 1) { + return useQuery({ + queryKey: ['budgets', page], + queryFn: () => api.get>(`/api/budgets?page=${page}`), + }); + } + ``` + +### Phase 5 — Frontend: add pagination UI + +10. Create a reusable `Paginator` component: + ```tsx + interface PaginatorProps { page: number; totalPages: number; onPageChange: (p: number) => void; } + ``` + Renders Prev / page number / Next buttons; disables Prev on page 1, Next on last page. +11. Add `const [page, setPage] = useState(1)` to each list page. +12. Wire `` below each list, passing `data.totalPages` and `setPage`. +13. Reset to page 1 when a new item is created or deleted (in the mutation `onSuccess`). + +### Phase 6 — Build and verify + +14. `dotnet build` (backend) — zero errors. +15. `npm run build` (frontend) — zero TypeScript errors. +16. Verify with more than `DefaultPageSize` items that next/prev navigation works. + +## Key decisions + +- `page` is 1-indexed throughout (both API and UI), consistent with the stack doc. +- `pageSize` can be omitted from the query string; the server uses `DefaultPageSize`. + No client-side page size picker in this plan. +- The `Incomes` and `Outgos` lists are ordered by `SortOrder` — pagination must preserve + this ordering. The `Skip/Take` applies after the `OrderBy`, so this is automatic. +- Reorder endpoints (`PUT .../order`) send the full ordered ID list — they are unaffected + by pagination since they operate on the whole set. If lists grow very large this becomes + a concern, but it is out of scope here. + +## Files affected + +**Backend:** +- New: `Budget.Core/DTOs/PagedResult.cs` (or `Budget.Api/DTOs/`) +- New: `Budget.Infrastructure/Options/PaginationOptions.cs` (or `Budget.Api/`) +- `appsettings.json` +- `Program.cs` +- `Budget.Api/Controllers/BudgetsController.cs` +- `Budget.Api/Controllers/IncomesController.cs` +- `Budget.Api/Controllers/OutgosController.cs` +- `Budget.Api/Controllers/SharesController.cs` + +**Frontend:** +- `src/Budget.Client/src/types/index.ts` +- `src/Budget.Client/src/api/budgets.ts` (or `BudgetsPage.tsx` if #12 not done) +- `src/Budget.Client/src/api/incomes.ts` +- `src/Budget.Client/src/api/outgos.ts` +- `src/Budget.Client/src/api/shares.ts` +- New: `src/Budget.Client/src/components/Paginator.tsx` +- `src/Budget.Client/src/pages/BudgetsPage.tsx` +- `src/Budget.Client/src/pages/IncomePage.tsx` +- `src/Budget.Client/src/pages/OutgoPage.tsx` +- `src/Budget.Client/src/pages/SettingsPage.tsx` diff --git a/Budget.sln b/Budget.sln index 33bb506..fd6ec21 100644 --- a/Budget.sln +++ b/Budget.sln @@ -4,6 +4,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Budget.Api", "src\Budget.Api\Budget.Api.csproj", "{39EAC168-4C28-4259-8A96-8E7B4D95F22B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Budget.Core", "src\Budget.Core\Budget.Core.csproj", "{9F974F0A-457D-4FCA-8D79-078F896D00C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Budget.Infrastructure", "src\Budget.Infrastructure\Budget.Infrastructure.csproj", "{B52D57CF-3F59-4E49-B60E-E988AC90614F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,11 +30,37 @@ Global {39EAC168-4C28-4259-8A96-8E7B4D95F22B}.Release|x64.Build.0 = Release|Any CPU {39EAC168-4C28-4259-8A96-8E7B4D95F22B}.Release|x86.ActiveCfg = Release|Any CPU {39EAC168-4C28-4259-8A96-8E7B4D95F22B}.Release|x86.Build.0 = Release|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Debug|x64.Build.0 = Debug|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Debug|x86.Build.0 = Debug|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Release|Any CPU.Build.0 = Release|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Release|x64.ActiveCfg = Release|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Release|x64.Build.0 = Release|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Release|x86.ActiveCfg = Release|Any CPU + {9F974F0A-457D-4FCA-8D79-078F896D00C1}.Release|x86.Build.0 = Release|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Debug|x64.ActiveCfg = Debug|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Debug|x64.Build.0 = Debug|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Debug|x86.ActiveCfg = Debug|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Debug|x86.Build.0 = Debug|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Release|Any CPU.Build.0 = Release|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Release|x64.ActiveCfg = Release|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Release|x64.Build.0 = Release|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Release|x86.ActiveCfg = Release|Any CPU + {B52D57CF-3F59-4E49-B60E-E988AC90614F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {39EAC168-4C28-4259-8A96-8E7B4D95F22B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9F974F0A-457D-4FCA-8D79-078F896D00C1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {B52D57CF-3F59-4E49-B60E-E988AC90614F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/Dockerfile b/Dockerfile index 87ae264..eb625cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,8 @@ RUN npm run build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api-build WORKDIR /app COPY Budget.sln ./ +COPY src/Budget.Core/ ./src/Budget.Core/ +COPY src/Budget.Infrastructure/ ./src/Budget.Infrastructure/ COPY src/Budget.Api/ ./src/Budget.Api/ RUN dotnet publish src/Budget.Api/Budget.Api.csproj -c Release -o /publish diff --git a/src/Budget.Api/Budget.Api.csproj b/src/Budget.Api/Budget.Api.csproj index 4c47c15..48be54e 100644 --- a/src/Budget.Api/Budget.Api.csproj +++ b/src/Budget.Api/Budget.Api.csproj @@ -8,13 +8,16 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive all - + + + + + diff --git a/src/Budget.Api/Controllers/BudgetsController.cs b/src/Budget.Api/Controllers/BudgetsController.cs index e180ea6..f094146 100644 --- a/src/Budget.Api/Controllers/BudgetsController.cs +++ b/src/Budget.Api/Controllers/BudgetsController.cs @@ -1,11 +1,13 @@ -using Budget.Api.Data; -using Budget.Api.DTOs; -using Budget.Api.Models; using Budget.Api.Services; +using Budget.Core.DTOs; +using Budget.Core.Models; +using Budget.Infrastructure.Data; +using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using BudgetEntity = Budget.Core.Models.Budget; namespace Budget.Api.Controllers; @@ -39,7 +41,7 @@ public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz public async Task Create([FromBody] CreateBudgetRequest req) { if (TryGetUserId(out var userId) is { } err) return err; - var budget = new Models.Budget + var budget = new BudgetEntity { Id = Guid.NewGuid(), Name = req.Name, diff --git a/src/Budget.Api/Controllers/IncomesController.cs b/src/Budget.Api/Controllers/IncomesController.cs index 401f1fa..b961019 100644 --- a/src/Budget.Api/Controllers/IncomesController.cs +++ b/src/Budget.Api/Controllers/IncomesController.cs @@ -1,7 +1,9 @@ -using Budget.Api.Data; -using Budget.Api.DTOs; -using Budget.Api.Models; using Budget.Api.Services; +using Budget.Core.DTOs; +using Budget.Core.Models; +using Budget.Core.Services; +using Budget.Infrastructure.Data; +using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; diff --git a/src/Budget.Api/Controllers/OutgosController.cs b/src/Budget.Api/Controllers/OutgosController.cs index 0c74b7b..24770c7 100644 --- a/src/Budget.Api/Controllers/OutgosController.cs +++ b/src/Budget.Api/Controllers/OutgosController.cs @@ -1,7 +1,9 @@ -using Budget.Api.Data; -using Budget.Api.DTOs; -using Budget.Api.Models; using Budget.Api.Services; +using Budget.Core.DTOs; +using Budget.Core.Models; +using Budget.Core.Services; +using Budget.Infrastructure.Data; +using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; diff --git a/src/Budget.Api/Controllers/SharesController.cs b/src/Budget.Api/Controllers/SharesController.cs index 08c6d5d..fc973c8 100644 --- a/src/Budget.Api/Controllers/SharesController.cs +++ b/src/Budget.Api/Controllers/SharesController.cs @@ -1,7 +1,8 @@ -using Budget.Api.Data; -using Budget.Api.DTOs; -using Budget.Api.Models; using Budget.Api.Services; +using Budget.Core.DTOs; +using Budget.Core.Models; +using Budget.Infrastructure.Data; +using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; diff --git a/src/Budget.Api/Controllers/SummaryController.cs b/src/Budget.Api/Controllers/SummaryController.cs index dd1e449..26c2c88 100644 --- a/src/Budget.Api/Controllers/SummaryController.cs +++ b/src/Budget.Api/Controllers/SummaryController.cs @@ -1,7 +1,9 @@ -using Budget.Api.Data; -using Budget.Api.DTOs; -using Budget.Api.Models; using Budget.Api.Services; +using Budget.Core.DTOs; +using Budget.Core.Models; +using Budget.Core.Services; +using Budget.Infrastructure.Data; +using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; diff --git a/src/Budget.Api/Program.cs b/src/Budget.Api/Program.cs index 2ffe153..168867c 100644 --- a/src/Budget.Api/Program.cs +++ b/src/Budget.Api/Program.cs @@ -1,6 +1,7 @@ using System.Threading.RateLimiting; -using Budget.Api.Data; using Budget.Api.Services; +using Budget.Infrastructure.Data; +using Budget.Infrastructure.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.HttpOverrides; diff --git a/src/Budget.Api/Services/KnownUserMiddleware.cs b/src/Budget.Api/Services/KnownUserMiddleware.cs index e39a8da..c41ac76 100644 --- a/src/Budget.Api/Services/KnownUserMiddleware.cs +++ b/src/Budget.Api/Services/KnownUserMiddleware.cs @@ -1,5 +1,5 @@ -using Budget.Api.Data; -using Budget.Api.Models; +using Budget.Core.Models; +using Budget.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace Budget.Api.Services; diff --git a/src/Budget.Core/Budget.Core.csproj b/src/Budget.Core/Budget.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/Budget.Core/Budget.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Budget.Api/DTOs/BudgetDtos.cs b/src/Budget.Core/DTOs/BudgetDtos.cs similarity index 92% rename from src/Budget.Api/DTOs/BudgetDtos.cs rename to src/Budget.Core/DTOs/BudgetDtos.cs index e47a699..4b91d53 100644 --- a/src/Budget.Api/DTOs/BudgetDtos.cs +++ b/src/Budget.Core/DTOs/BudgetDtos.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Budget.Api.DTOs; +namespace Budget.Core.DTOs; public record BudgetDto(Guid Id, string Name, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); diff --git a/src/Budget.Api/DTOs/IncomeDtos.cs b/src/Budget.Core/DTOs/IncomeDtos.cs similarity index 92% rename from src/Budget.Api/DTOs/IncomeDtos.cs rename to src/Budget.Core/DTOs/IncomeDtos.cs index 0bed93f..35397bc 100644 --- a/src/Budget.Api/DTOs/IncomeDtos.cs +++ b/src/Budget.Core/DTOs/IncomeDtos.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; -using Budget.Api.Models; +using Budget.Core.Models; -namespace Budget.Api.DTOs; +namespace Budget.Core.DTOs; public record IncomeDto( Guid Id, diff --git a/src/Budget.Api/DTOs/OutgoDtos.cs b/src/Budget.Core/DTOs/OutgoDtos.cs similarity index 94% rename from src/Budget.Api/DTOs/OutgoDtos.cs rename to src/Budget.Core/DTOs/OutgoDtos.cs index 81e8811..4d5f2d5 100644 --- a/src/Budget.Api/DTOs/OutgoDtos.cs +++ b/src/Budget.Core/DTOs/OutgoDtos.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; -using Budget.Api.Models; +using Budget.Core.Models; -namespace Budget.Api.DTOs; +namespace Budget.Core.DTOs; public record OutgoDto( Guid Id, diff --git a/src/Budget.Api/DTOs/ShareDtos.cs b/src/Budget.Core/DTOs/ShareDtos.cs similarity index 86% rename from src/Budget.Api/DTOs/ShareDtos.cs rename to src/Budget.Core/DTOs/ShareDtos.cs index 3e3f1de..1f551b9 100644 --- a/src/Budget.Api/DTOs/ShareDtos.cs +++ b/src/Budget.Core/DTOs/ShareDtos.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; -using Budget.Api.Models; +using Budget.Core.Models; -namespace Budget.Api.DTOs; +namespace Budget.Core.DTOs; public record ShareDto(Guid Id, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt); diff --git a/src/Budget.Api/DTOs/SummaryDtos.cs b/src/Budget.Core/DTOs/SummaryDtos.cs similarity index 95% rename from src/Budget.Api/DTOs/SummaryDtos.cs rename to src/Budget.Core/DTOs/SummaryDtos.cs index 36ca441..6ba8e00 100644 --- a/src/Budget.Api/DTOs/SummaryDtos.cs +++ b/src/Budget.Core/DTOs/SummaryDtos.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.DTOs; +namespace Budget.Core.DTOs; public record SummaryBreakdownItem( string Type, diff --git a/src/Budget.Api/Models/Budget.cs b/src/Budget.Core/Models/Budget.cs similarity index 94% rename from src/Budget.Api/Models/Budget.cs rename to src/Budget.Core/Models/Budget.cs index 102e620..8214d7a 100644 --- a/src/Budget.Api/Models/Budget.cs +++ b/src/Budget.Core/Models/Budget.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public class Budget { diff --git a/src/Budget.Api/Models/BudgetShare.cs b/src/Budget.Core/Models/BudgetShare.cs similarity index 93% rename from src/Budget.Api/Models/BudgetShare.cs rename to src/Budget.Core/Models/BudgetShare.cs index 00b563b..c6e196a 100644 --- a/src/Budget.Api/Models/BudgetShare.cs +++ b/src/Budget.Core/Models/BudgetShare.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public class BudgetShare { diff --git a/src/Budget.Api/Models/Frequency.cs b/src/Budget.Core/Models/Frequency.cs similarity index 84% rename from src/Budget.Api/Models/Frequency.cs rename to src/Budget.Core/Models/Frequency.cs index 2876626..3ce7a43 100644 --- a/src/Budget.Api/Models/Frequency.cs +++ b/src/Budget.Core/Models/Frequency.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public enum Frequency { diff --git a/src/Budget.Api/Models/Income.cs b/src/Budget.Core/Models/Income.cs similarity index 91% rename from src/Budget.Api/Models/Income.cs rename to src/Budget.Core/Models/Income.cs index d44cc68..7b1ea16 100644 --- a/src/Budget.Api/Models/Income.cs +++ b/src/Budget.Core/Models/Income.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public class Income { diff --git a/src/Budget.Api/Models/KnownUser.cs b/src/Budget.Core/Models/KnownUser.cs similarity index 87% rename from src/Budget.Api/Models/KnownUser.cs rename to src/Budget.Core/Models/KnownUser.cs index ef24a6b..01a52f5 100644 --- a/src/Budget.Api/Models/KnownUser.cs +++ b/src/Budget.Core/Models/KnownUser.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public class KnownUser { diff --git a/src/Budget.Api/Models/Outgo.cs b/src/Budget.Core/Models/Outgo.cs similarity index 94% rename from src/Budget.Api/Models/Outgo.cs rename to src/Budget.Core/Models/Outgo.cs index ee909ab..2997b0a 100644 --- a/src/Budget.Api/Models/Outgo.cs +++ b/src/Budget.Core/Models/Outgo.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public class Outgo { diff --git a/src/Budget.Api/Models/OutgoType.cs b/src/Budget.Core/Models/OutgoType.cs similarity index 65% rename from src/Budget.Api/Models/OutgoType.cs rename to src/Budget.Core/Models/OutgoType.cs index 94ce187..60e9085 100644 --- a/src/Budget.Api/Models/OutgoType.cs +++ b/src/Budget.Core/Models/OutgoType.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public enum OutgoType { diff --git a/src/Budget.Api/Models/SharePermission.cs b/src/Budget.Core/Models/SharePermission.cs similarity index 63% rename from src/Budget.Api/Models/SharePermission.cs rename to src/Budget.Core/Models/SharePermission.cs index 7417e7c..09f2e74 100644 --- a/src/Budget.Api/Models/SharePermission.cs +++ b/src/Budget.Core/Models/SharePermission.cs @@ -1,4 +1,4 @@ -namespace Budget.Api.Models; +namespace Budget.Core.Models; public enum SharePermission { diff --git a/src/Budget.Api/Services/FrequencyCalculator.cs b/src/Budget.Core/Services/FrequencyCalculator.cs similarity index 93% rename from src/Budget.Api/Services/FrequencyCalculator.cs rename to src/Budget.Core/Services/FrequencyCalculator.cs index 24415ad..cf1a8a3 100644 --- a/src/Budget.Api/Services/FrequencyCalculator.cs +++ b/src/Budget.Core/Services/FrequencyCalculator.cs @@ -1,6 +1,6 @@ -using Budget.Api.Models; +using Budget.Core.Models; -namespace Budget.Api.Services; +namespace Budget.Core.Services; public static class FrequencyCalculator { diff --git a/src/Budget.Infrastructure/Budget.Infrastructure.csproj b/src/Budget.Infrastructure/Budget.Infrastructure.csproj new file mode 100644 index 0000000..9c1c282 --- /dev/null +++ b/src/Budget.Infrastructure/Budget.Infrastructure.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Budget.Api/Data/AppDbContext.cs b/src/Budget.Infrastructure/Data/AppDbContext.cs similarity index 91% rename from src/Budget.Api/Data/AppDbContext.cs rename to src/Budget.Infrastructure/Data/AppDbContext.cs index abb1b7b..045fb2e 100644 --- a/src/Budget.Api/Data/AppDbContext.cs +++ b/src/Budget.Infrastructure/Data/AppDbContext.cs @@ -1,11 +1,11 @@ -using Budget.Api.Models; +using Budget.Core.Models; using Microsoft.EntityFrameworkCore; -namespace Budget.Api.Data; +namespace Budget.Infrastructure.Data; public class AppDbContext(DbContextOptions options) : DbContext(options) { - public DbSet Budgets => Set(); + public DbSet Budgets => Set(); public DbSet Incomes => Set(); public DbSet Outgos => Set(); public DbSet KnownUsers => Set(); @@ -13,7 +13,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(b => + modelBuilder.Entity(b => { b.HasKey(x => x.Id); b.Property(x => x.Name).IsRequired().HasMaxLength(200); diff --git a/src/Budget.Api/Data/Migrations/20260425123657_InitialCreate.Designer.cs b/src/Budget.Infrastructure/Data/Migrations/20260425123657_InitialCreate.Designer.cs similarity index 89% rename from src/Budget.Api/Data/Migrations/20260425123657_InitialCreate.Designer.cs rename to src/Budget.Infrastructure/Data/Migrations/20260425123657_InitialCreate.Designer.cs index 7da27d9..b326896 100644 --- a/src/Budget.Api/Data/Migrations/20260425123657_InitialCreate.Designer.cs +++ b/src/Budget.Infrastructure/Data/Migrations/20260425123657_InitialCreate.Designer.cs @@ -1,6 +1,6 @@ -// +// using System; -using Budget.Api.Data; +using Budget.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -9,7 +9,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Budget.Api.Data.Migrations +namespace Budget.Infrastructure.Data.Migrations { [DbContext(typeof(AppDbContext))] [Migration("20260425123657_InitialCreate")] @@ -25,7 +25,7 @@ namespace Budget.Api.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Budget.Api.Models.Budget", b => + modelBuilder.Entity("Budget.Core.Models.Budget", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -56,7 +56,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("Budgets"); }); - modelBuilder.Entity("Budget.Api.Models.BudgetShare", b => + modelBuilder.Entity("Budget.Core.Models.BudgetShare", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -91,7 +91,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("BudgetShares"); }); - modelBuilder.Entity("Budget.Api.Models.Income", b => + modelBuilder.Entity("Budget.Core.Models.Income", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -122,7 +122,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("Incomes"); }); - modelBuilder.Entity("Budget.Api.Models.KnownUser", b => + modelBuilder.Entity("Budget.Core.Models.KnownUser", b => { b.Property("Id") .HasMaxLength(200) @@ -146,7 +146,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("KnownUsers"); }); - modelBuilder.Entity("Budget.Api.Models.Outgo", b => + modelBuilder.Entity("Budget.Core.Models.Outgo", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -192,9 +192,9 @@ namespace Budget.Api.Data.Migrations b.ToTable("Outgos"); }); - modelBuilder.Entity("Budget.Api.Models.BudgetShare", b => + modelBuilder.Entity("Budget.Core.Models.BudgetShare", b => { - b.HasOne("Budget.Api.Models.Budget", "Budget") + b.HasOne("Budget.Core.Models.Budget", "Budget") .WithMany("Shares") .HasForeignKey("BudgetId") .OnDelete(DeleteBehavior.Cascade) @@ -203,9 +203,9 @@ namespace Budget.Api.Data.Migrations b.Navigation("Budget"); }); - modelBuilder.Entity("Budget.Api.Models.Income", b => + modelBuilder.Entity("Budget.Core.Models.Income", b => { - b.HasOne("Budget.Api.Models.Budget", "Budget") + b.HasOne("Budget.Core.Models.Budget", "Budget") .WithMany("Incomes") .HasForeignKey("BudgetId") .OnDelete(DeleteBehavior.Cascade) @@ -214,9 +214,9 @@ namespace Budget.Api.Data.Migrations b.Navigation("Budget"); }); - modelBuilder.Entity("Budget.Api.Models.Outgo", b => + modelBuilder.Entity("Budget.Core.Models.Outgo", b => { - b.HasOne("Budget.Api.Models.Budget", "Budget") + b.HasOne("Budget.Core.Models.Budget", "Budget") .WithMany("Outgos") .HasForeignKey("BudgetId") .OnDelete(DeleteBehavior.Cascade) @@ -225,7 +225,7 @@ namespace Budget.Api.Data.Migrations b.Navigation("Budget"); }); - modelBuilder.Entity("Budget.Api.Models.Budget", b => + modelBuilder.Entity("Budget.Core.Models.Budget", b => { b.Navigation("Incomes"); diff --git a/src/Budget.Api/Data/Migrations/20260425123657_InitialCreate.cs b/src/Budget.Infrastructure/Data/Migrations/20260425123657_InitialCreate.cs similarity index 99% rename from src/Budget.Api/Data/Migrations/20260425123657_InitialCreate.cs rename to src/Budget.Infrastructure/Data/Migrations/20260425123657_InitialCreate.cs index ea3989b..3a9f679 100644 --- a/src/Budget.Api/Data/Migrations/20260425123657_InitialCreate.cs +++ b/src/Budget.Infrastructure/Data/Migrations/20260425123657_InitialCreate.cs @@ -1,9 +1,9 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Budget.Api.Data.Migrations +namespace Budget.Infrastructure.Data.Migrations { /// public partial class InitialCreate : Migration diff --git a/src/Budget.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Budget.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs similarity index 88% rename from src/Budget.Api/Data/Migrations/AppDbContextModelSnapshot.cs rename to src/Budget.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 6108045..88ee802 100644 --- a/src/Budget.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Budget.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -1,6 +1,6 @@ -// +// using System; -using Budget.Api.Data; +using Budget.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Budget.Api.Data.Migrations +namespace Budget.Infrastructure.Data.Migrations { [DbContext(typeof(AppDbContext))] partial class AppDbContextModelSnapshot : ModelSnapshot @@ -22,7 +22,7 @@ namespace Budget.Api.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Budget.Api.Models.Budget", b => + modelBuilder.Entity("Budget.Core.Models.Budget", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -53,7 +53,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("Budgets"); }); - modelBuilder.Entity("Budget.Api.Models.BudgetShare", b => + modelBuilder.Entity("Budget.Core.Models.BudgetShare", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -88,7 +88,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("BudgetShares"); }); - modelBuilder.Entity("Budget.Api.Models.Income", b => + modelBuilder.Entity("Budget.Core.Models.Income", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -119,7 +119,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("Incomes"); }); - modelBuilder.Entity("Budget.Api.Models.KnownUser", b => + modelBuilder.Entity("Budget.Core.Models.KnownUser", b => { b.Property("Id") .HasMaxLength(200) @@ -143,7 +143,7 @@ namespace Budget.Api.Data.Migrations b.ToTable("KnownUsers"); }); - modelBuilder.Entity("Budget.Api.Models.Outgo", b => + modelBuilder.Entity("Budget.Core.Models.Outgo", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -189,9 +189,9 @@ namespace Budget.Api.Data.Migrations b.ToTable("Outgos"); }); - modelBuilder.Entity("Budget.Api.Models.BudgetShare", b => + modelBuilder.Entity("Budget.Core.Models.BudgetShare", b => { - b.HasOne("Budget.Api.Models.Budget", "Budget") + b.HasOne("Budget.Core.Models.Budget", "Budget") .WithMany("Shares") .HasForeignKey("BudgetId") .OnDelete(DeleteBehavior.Cascade) @@ -200,9 +200,9 @@ namespace Budget.Api.Data.Migrations b.Navigation("Budget"); }); - modelBuilder.Entity("Budget.Api.Models.Income", b => + modelBuilder.Entity("Budget.Core.Models.Income", b => { - b.HasOne("Budget.Api.Models.Budget", "Budget") + b.HasOne("Budget.Core.Models.Budget", "Budget") .WithMany("Incomes") .HasForeignKey("BudgetId") .OnDelete(DeleteBehavior.Cascade) @@ -211,9 +211,9 @@ namespace Budget.Api.Data.Migrations b.Navigation("Budget"); }); - modelBuilder.Entity("Budget.Api.Models.Outgo", b => + modelBuilder.Entity("Budget.Core.Models.Outgo", b => { - b.HasOne("Budget.Api.Models.Budget", "Budget") + b.HasOne("Budget.Core.Models.Budget", "Budget") .WithMany("Outgos") .HasForeignKey("BudgetId") .OnDelete(DeleteBehavior.Cascade) @@ -222,7 +222,7 @@ namespace Budget.Api.Data.Migrations b.Navigation("Budget"); }); - modelBuilder.Entity("Budget.Api.Models.Budget", b => + modelBuilder.Entity("Budget.Core.Models.Budget", b => { b.Navigation("Incomes"); diff --git a/src/Budget.Api/Services/BudgetAuthorizationService.cs b/src/Budget.Infrastructure/Services/BudgetAuthorizationService.cs similarity index 93% rename from src/Budget.Api/Services/BudgetAuthorizationService.cs rename to src/Budget.Infrastructure/Services/BudgetAuthorizationService.cs index 7ecd684..c5d82fd 100644 --- a/src/Budget.Api/Services/BudgetAuthorizationService.cs +++ b/src/Budget.Infrastructure/Services/BudgetAuthorizationService.cs @@ -1,8 +1,8 @@ -using Budget.Api.Data; -using Budget.Api.Models; +using Budget.Core.Models; +using Budget.Infrastructure.Data; using Microsoft.EntityFrameworkCore; -namespace Budget.Api.Services; +namespace Budget.Infrastructure.Services; public enum BudgetAccess { None, View, Edit, Owner }