Split into Budget.Core / Budget.Infrastructure / Budget.Api projects

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 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-05-02 16:30:31 -05:00
parent c3d1420c4c
commit 087fbdd176
37 changed files with 826 additions and 78 deletions
+90
View File
@@ -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)
+86
View File
@@ -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 `<AuthProvider>`
## Target state
- Remove `AuthContext.tsx`.
- `main.tsx` wraps the app in `<AuthProvider>` 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: `<AuthProvider {...authConfig} onSigninCallback={...}>`.
- Remove the old `<AuthProvider>` 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 `<TokenSync />` inside `<AuthProvider>` 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`
+123
View File
@@ -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<BudgetDto[]>([]);
useEffect(() => {
api.get<BudgetDto[]>('/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 `<QueryClientProvider client={queryClient}>` in `main.tsx`
(inside `<AuthProvider>`).
### 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<BudgetDto[]>('/api/budgets') });
}
export function useCreateBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: CreateBudgetRequest) => api.post<BudgetDto>('/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`
+126
View File
@@ -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<CreateBudgetRequest>({
resolver: zodResolver(createBudgetSchema),
});
const onSubmit = async (data: CreateBudgetRequest) => {
await createBudget.mutateAsync(data);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<button type="submit" disabled={isSubmitting}>Create</button>
</form>
);
```
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 `<input type="number">` 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`
+106
View File
@@ -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
+145
View File
@@ -0,0 +1,145 @@
# Plan: Add PagedResult<T> Pagination
## Goal
Make all list endpoints return `PagedResult<T>` 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<T>` record:
```csharp
public record PagedResult<T>(IReadOnlyList<T> 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<PaginationOptions>` in Program.cs:
```csharp
builder.Services.Configure<PaginationOptions>(builder.Configuration.GetSection("Pagination"));
```
with a `PaginationOptions` record/class containing `DefaultPageSize` and `MaxPageSize`.
### Phase 2 — Backend: update list endpoints
4. Inject `IOptions<PaginationOptions>` 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<T>`:
```csharp
var total = await query.CountAsync();
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
return Ok(new PagedResult<BudgetDto>(items, page, pageSize, total));
```
7. Affected actions: `BudgetsController.List`, `IncomesController.List`,
`OutgosController.List`, `SharesController.List`.
### Phase 3 — Frontend: update types
8. Add `PagedResult<T>` to `src/Budget.Client/src/types/index.ts`:
```ts
export interface PagedResult<T> {
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<T>`:
```ts
export function useBudgets(page = 1) {
return useQuery({
queryKey: ['budgets', page],
queryFn: () => api.get<PagedResult<BudgetDto>>(`/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 `<Paginator>` 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`
+30
View File
@@ -4,6 +4,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Budget.Api", "src\Budget.Api\Budget.Api.csproj", "{39EAC168-4C28-4259-8A96-8E7B4D95F22B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Budget.Api", "src\Budget.Api\Budget.Api.csproj", "{39EAC168-4C28-4259-8A96-8E7B4D95F22B}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{39EAC168-4C28-4259-8A96-8E7B4D95F22B}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{39EAC168-4C28-4259-8A96-8E7B4D95F22B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {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 EndGlobalSection
EndGlobal EndGlobal
+2
View File
@@ -11,6 +11,8 @@ RUN npm run build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api-build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api-build
WORKDIR /app WORKDIR /app
COPY Budget.sln ./ COPY Budget.sln ./
COPY src/Budget.Core/ ./src/Budget.Core/
COPY src/Budget.Infrastructure/ ./src/Budget.Infrastructure/
COPY src/Budget.Api/ ./src/Budget.Api/ COPY src/Budget.Api/ ./src/Budget.Api/
RUN dotnet publish src/Budget.Api/Budget.Api.csproj -c Release -o /publish RUN dotnet publish src/Budget.Api/Budget.Api.csproj -c Release -o /publish
+5 -2
View File
@@ -8,13 +8,16 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Budget.Core\Budget.Core.csproj" />
<ProjectReference Include="..\Budget.Infrastructure\Budget.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,11 +1,13 @@
using Budget.Api.Data;
using Budget.Api.DTOs;
using Budget.Api.Models;
using Budget.Api.Services; 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using BudgetEntity = Budget.Core.Models.Budget;
namespace Budget.Api.Controllers; namespace Budget.Api.Controllers;
@@ -39,7 +41,7 @@ public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest req) public async Task<IActionResult> Create([FromBody] CreateBudgetRequest req)
{ {
if (TryGetUserId(out var userId) is { } err) return err; if (TryGetUserId(out var userId) is { } err) return err;
var budget = new Models.Budget var budget = new BudgetEntity
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = req.Name, Name = req.Name,
@@ -1,7 +1,9 @@
using Budget.Api.Data;
using Budget.Api.DTOs;
using Budget.Api.Models;
using Budget.Api.Services; 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -1,7 +1,9 @@
using Budget.Api.Data;
using Budget.Api.DTOs;
using Budget.Api.Models;
using Budget.Api.Services; 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -1,7 +1,8 @@
using Budget.Api.Data;
using Budget.Api.DTOs;
using Budget.Api.Models;
using Budget.Api.Services; 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -1,7 +1,9 @@
using Budget.Api.Data;
using Budget.Api.DTOs;
using Budget.Api.Models;
using Budget.Api.Services; 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
+2 -1
View File
@@ -1,6 +1,7 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Budget.Api.Data;
using Budget.Api.Services; using Budget.Api.Services;
using Budget.Infrastructure.Data;
using Budget.Infrastructure.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
@@ -1,5 +1,5 @@
using Budget.Api.Data; using Budget.Core.Models;
using Budget.Api.Models; using Budget.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Budget.Api.Services; namespace Budget.Api.Services;
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations; 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); public record BudgetDto(Guid Id, string Name, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Budget.Api.Models; using Budget.Core.Models;
namespace Budget.Api.DTOs; namespace Budget.Core.DTOs;
public record IncomeDto( public record IncomeDto(
Guid Id, Guid Id,
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Budget.Api.Models; using Budget.Core.Models;
namespace Budget.Api.DTOs; namespace Budget.Core.DTOs;
public record OutgoDto( public record OutgoDto(
Guid Id, Guid Id,
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; 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); public record ShareDto(Guid Id, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt);
@@ -1,4 +1,4 @@
namespace Budget.Api.DTOs; namespace Budget.Core.DTOs;
public record SummaryBreakdownItem( public record SummaryBreakdownItem(
string Type, string Type,
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public class Budget public class Budget
{ {
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public class BudgetShare public class BudgetShare
{ {
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public enum Frequency public enum Frequency
{ {
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public class Income public class Income
{ {
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public class KnownUser public class KnownUser
{ {
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public class Outgo public class Outgo
{ {
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public enum OutgoType public enum OutgoType
{ {
@@ -1,4 +1,4 @@
namespace Budget.Api.Models; namespace Budget.Core.Models;
public enum SharePermission public enum SharePermission
{ {
@@ -1,6 +1,6 @@
using Budget.Api.Models; using Budget.Core.Models;
namespace Budget.Api.Services; namespace Budget.Core.Services;
public static class FrequencyCalculator public static class FrequencyCalculator
{ {
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Budget.Core\Budget.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,11 +1,11 @@
using Budget.Api.Models; using Budget.Core.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Budget.Api.Data; namespace Budget.Infrastructure.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{ {
public DbSet<Models.Budget> Budgets => Set<Models.Budget>(); public DbSet<Budget.Core.Models.Budget> Budgets => Set<Budget.Core.Models.Budget>();
public DbSet<Income> Incomes => Set<Income>(); public DbSet<Income> Incomes => Set<Income>();
public DbSet<Outgo> Outgos => Set<Outgo>(); public DbSet<Outgo> Outgos => Set<Outgo>();
public DbSet<KnownUser> KnownUsers => Set<KnownUser>(); public DbSet<KnownUser> KnownUsers => Set<KnownUser>();
@@ -13,7 +13,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<Models.Budget>(b => modelBuilder.Entity<Budget.Core.Models.Budget>(b =>
{ {
b.HasKey(x => x.Id); b.HasKey(x => x.Id);
b.Property(x => x.Name).IsRequired().HasMaxLength(200); b.Property(x => x.Name).IsRequired().HasMaxLength(200);
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Budget.Api.Data; using Budget.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
@@ -9,7 +9,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace Budget.Api.Data.Migrations namespace Budget.Infrastructure.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260425123657_InitialCreate")] [Migration("20260425123657_InitialCreate")]
@@ -25,7 +25,7 @@ namespace Budget.Api.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Budget.Api.Models.Budget", b => modelBuilder.Entity("Budget.Core.Models.Budget", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -56,7 +56,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("Budgets"); b.ToTable("Budgets");
}); });
modelBuilder.Entity("Budget.Api.Models.BudgetShare", b => modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -91,7 +91,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("BudgetShares"); b.ToTable("BudgetShares");
}); });
modelBuilder.Entity("Budget.Api.Models.Income", b => modelBuilder.Entity("Budget.Core.Models.Income", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -122,7 +122,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("Incomes"); b.ToTable("Incomes");
}); });
modelBuilder.Entity("Budget.Api.Models.KnownUser", b => modelBuilder.Entity("Budget.Core.Models.KnownUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(200) .HasMaxLength(200)
@@ -146,7 +146,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("KnownUsers"); b.ToTable("KnownUsers");
}); });
modelBuilder.Entity("Budget.Api.Models.Outgo", b => modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -192,9 +192,9 @@ namespace Budget.Api.Data.Migrations
b.ToTable("Outgos"); 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") .WithMany("Shares")
.HasForeignKey("BudgetId") .HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -203,9 +203,9 @@ namespace Budget.Api.Data.Migrations
b.Navigation("Budget"); 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") .WithMany("Incomes")
.HasForeignKey("BudgetId") .HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -214,9 +214,9 @@ namespace Budget.Api.Data.Migrations
b.Navigation("Budget"); 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") .WithMany("Outgos")
.HasForeignKey("BudgetId") .HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -225,7 +225,7 @@ namespace Budget.Api.Data.Migrations
b.Navigation("Budget"); b.Navigation("Budget");
}); });
modelBuilder.Entity("Budget.Api.Models.Budget", b => modelBuilder.Entity("Budget.Core.Models.Budget", b =>
{ {
b.Navigation("Incomes"); b.Navigation("Incomes");
@@ -1,9 +1,9 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Budget.Api.Data.Migrations namespace Budget.Infrastructure.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialCreate : Migration public partial class InitialCreate : Migration
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Budget.Api.Data; using Budget.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace Budget.Api.Data.Migrations namespace Budget.Infrastructure.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot partial class AppDbContextModelSnapshot : ModelSnapshot
@@ -22,7 +22,7 @@ namespace Budget.Api.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Budget.Api.Models.Budget", b => modelBuilder.Entity("Budget.Core.Models.Budget", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -53,7 +53,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("Budgets"); b.ToTable("Budgets");
}); });
modelBuilder.Entity("Budget.Api.Models.BudgetShare", b => modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -88,7 +88,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("BudgetShares"); b.ToTable("BudgetShares");
}); });
modelBuilder.Entity("Budget.Api.Models.Income", b => modelBuilder.Entity("Budget.Core.Models.Income", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -119,7 +119,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("Incomes"); b.ToTable("Incomes");
}); });
modelBuilder.Entity("Budget.Api.Models.KnownUser", b => modelBuilder.Entity("Budget.Core.Models.KnownUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(200) .HasMaxLength(200)
@@ -143,7 +143,7 @@ namespace Budget.Api.Data.Migrations
b.ToTable("KnownUsers"); b.ToTable("KnownUsers");
}); });
modelBuilder.Entity("Budget.Api.Models.Outgo", b => modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -189,9 +189,9 @@ namespace Budget.Api.Data.Migrations
b.ToTable("Outgos"); 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") .WithMany("Shares")
.HasForeignKey("BudgetId") .HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -200,9 +200,9 @@ namespace Budget.Api.Data.Migrations
b.Navigation("Budget"); 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") .WithMany("Incomes")
.HasForeignKey("BudgetId") .HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -211,9 +211,9 @@ namespace Budget.Api.Data.Migrations
b.Navigation("Budget"); 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") .WithMany("Outgos")
.HasForeignKey("BudgetId") .HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -222,7 +222,7 @@ namespace Budget.Api.Data.Migrations
b.Navigation("Budget"); b.Navigation("Budget");
}); });
modelBuilder.Entity("Budget.Api.Models.Budget", b => modelBuilder.Entity("Budget.Core.Models.Budget", b =>
{ {
b.Navigation("Incomes"); b.Navigation("Incomes");
@@ -1,8 +1,8 @@
using Budget.Api.Data; using Budget.Core.Models;
using Budget.Api.Models; using Budget.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Budget.Api.Services; namespace Budget.Infrastructure.Services;
public enum BudgetAccess { None, View, Edit, Owner } public enum BudgetAccess { None, View, Edit, Owner }