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:
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<IActionResult> 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
namespace Budget.Api.DTOs;
|
||||
namespace Budget.Core.DTOs;
|
||||
|
||||
public record BudgetDto(Guid Id, string Name, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.DTOs;
|
||||
namespace Budget.Core.DTOs;
|
||||
|
||||
public record SummaryBreakdownItem(
|
||||
string Type,
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public class Budget
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public class BudgetShare
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public enum Frequency
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public class Income
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public class KnownUser
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public class Outgo
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public enum OutgoType
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace Budget.Api.Models;
|
||||
namespace Budget.Core.Models;
|
||||
|
||||
public enum SharePermission
|
||||
{
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using Budget.Api.Models;
|
||||
using Budget.Core.Models;
|
||||
|
||||
namespace Budget.Api.Services;
|
||||
namespace Budget.Core.Services;
|
||||
|
||||
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>
|
||||
+4
-4
@@ -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<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<Outgo> Outgos => Set<Outgo>();
|
||||
public DbSet<KnownUser> KnownUsers => Set<KnownUser>();
|
||||
@@ -13,7 +13,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Models.Budget>(b =>
|
||||
modelBuilder.Entity<Budget.Core.Models.Budget>(b =>
|
||||
{
|
||||
b.HasKey(x => x.Id);
|
||||
b.Property(x => x.Name).IsRequired().HasMaxLength(200);
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
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<Guid>("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<Guid>("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<Guid>("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<string>("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<Guid>("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");
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Budget.Api.Data.Migrations
|
||||
namespace Budget.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
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<Guid>("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<Guid>("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<Guid>("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<string>("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<Guid>("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");
|
||||
|
||||
+3
-3
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user