Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .bob/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tmp/
16 changes: 16 additions & 0 deletions .bob/rules-agent/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

## Coding Rules (Non-Obvious)

- **Dual controller surface**: Every entity needs both an OData controller (`Controllers/OData/`, extends `DefaultController`) and a REST controller (`Controllers/`, extends `DefaultRestController`). Adding only one is incomplete.
- **`Program.cs` partial class**: The `public partial class Program { }` at the end of `PersonalWeb.Api/Program.cs` is required for `WebApplicationFactory<Program>` in integration tests — do not remove it.
- **No implicit usings in `PersonalWeb.Api`**: Unlike AppHost/MigrationService, `PersonalWeb.Api.csproj` does NOT enable `ImplicitUsings`. All `using` directives must be written explicitly.
- **Soft delete only**: Never call `_dbContext.Remove()`. `Delete()` in `DefaultModelRepositories` sets `IsDeleted = true`. All queries go through `GetAll()` which filters `!x.IsDeleted`.
- **Throw `EntityNotFoundException`, not HTTP results from repositories**: Repositories throw `EntityNotFoundException` (from `PersonalWeb.Api/Exceptions/`); controllers map this to HTTP responses.
- **Models are records**: Entity classes are `record` types, not `class`. Keep this consistent for new entities.
- **Frontend proxy env var**: The dev proxy in `setupProxy.js` uses `process.env.services__apiservice__apiservice__0` — this is injected by Aspire at runtime, not a `.env` file variable.
- **Frontend tests from `PersonalWeb.Admin/`**: Jest and `yarn test` must be run from the `PersonalWeb.Admin/` directory. Running from repo root will fail.
- **Integration tests need a live PostgreSQL**: The `PersonalWeb.Api.Integration.Test` project connects to a real Postgres. Set `ConnectionStrings__Default` env var or ensure `appsettings.Development.json` points to a running instance.
- **DB migrations via CLI before running integration tests**: Run `dotnet ef database update --project PersonalWeb.Api` before executing integration tests locally.
12 changes: 12 additions & 0 deletions .bob/rules-ask/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

## Documentation Context (Non-Obvious)

- **Two API layers**: The API project exposes both OData (`/v1/`) and a plain REST layer (`/api/[controller]/[action]`). They are independent controller hierarchies sharing the same repositories. Documentation or questions about one don't necessarily apply to the other.
- **Frontend is CRA, not Vite**: Despite the project being modern (React 19), `PersonalWeb.Admin` uses Create React App (`react-scripts`), not Vite. Vite-specific docs don't apply.
- **`ra-data-odata-server`**: The frontend uses an OData-specific data provider for react-admin (`ra-data-odata-server`), not the standard REST or JSON API providers. Query/filter semantics follow OData conventions.
- **Aspire SDK version mismatch in csproj**: `PersonalWeb.AppHost.csproj` declares `Aspire.AppHost.Sdk` at version `9.5.2` but the hosting packages reference `13.x`. This is intentional — the SDK and hosting packages have independent versioning.
- **`MigrationService` is the production migration runner**: EF migrations are not auto-applied at API startup. The `PersonalWeb.MigrationService` worker is the designed mechanism and is wired in Aspire with `WithExplicitStart()` and `WaitForCompletion`.
- **OData route base is `/v1/`**: All OData endpoints are under `/v1/EntitySetName` (e.g., `/v1/Blogs`). The REST endpoints are under `/api/Blogs/Get`, `/api/Blogs/Post`, etc.
13 changes: 13 additions & 0 deletions .bob/rules-plan/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

## Architecture Constraints (Non-Obvious)

- **Dual controller pattern is intentional**: The OData surface (`Controllers/OData/`) uses `[EnableQuery]`, `Delta<T>` for PATCH, and OData-specific semantics. The REST surface (`Controllers/`) uses standard `[HttpGet/Post/Put/Delete]` attributes and is simpler. Adding a new entity requires both controllers.
- **Repository layer is mandatory**: Controllers never access `DbContext` directly. All data access goes through `IDefaultModelRepositories<TEntity>` / `DefaultModelRepositories<TEntity, TContext>`. Raw EF queries in controllers would break the pattern.
- **`DefaultModel` is the single base record**: All entities inherit the abstract `DefaultModel` record, which brings in both `IAuditable` and `IDraftable`. There is no separate audit log table — audit fields are columns on each entity table.
- **Soft delete is system-wide**: `GetAll()` always filters `IsDeleted = false`. Any new query that bypasses `GetAll()` will expose soft-deleted records unintentionally.
- **MigrationService controls schema evolution**: EF migrations are authored in `PersonalWeb.Api/Migrations/` but applied by `PersonalWeb.MigrationService` at startup. The API itself does not run migrations. In Aspire, the API waits for migration completion (`WaitForCompletion(migration)`).
- **Integration tests are stateful**: Each `IClassFixture<WebApplicationFactory<Program>>` test class shares a single factory (and DB connection). `Dispose()` must call `ExecuteDelete()` to clean up; tests that don't implement `IDisposable` leave data that can affect later tests.
- **Aspire wires the frontend URL via env**: The React admin app's API URL is not hardcoded — it comes from `services__apiservice__apiservice__0` injected by Aspire. In standalone mode (without Aspire), the proxy won't resolve and the frontend will fail to reach the API.
1 change: 1 addition & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
branches: [ main ]
env:
ConnectionStrings__Default: "Host=localhost;Database=personalwebapi_test;Username=postgres;Password=testpass123"
Jwt__Secret: test-secret-for-ci-minimum-length-ok
jobs:
build:
runs-on: ubuntu-24.04
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -482,3 +482,5 @@ $RECYCLE.BIN/

# Vim temporary swap files
*.swp
# Playwright CLI output (may contain credentials)
.playwright-cli/
77 changes: 77 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

## Stack

- **Backend**: .NET 10, ASP.NET Core Web API, EF Core 10 + Npgsql (PostgreSQL), OData v4 (Microsoft.AspNetCore.OData 9.x), Swagger
- **Frontend**: React 19 + react-admin 5 + `ra-data-odata-server` (OData provider), TypeScript, Jest + ts-jest, Yarn (v1.22)
- **Orchestration**: .NET Aspire 13 (`PersonalWeb.AppHost`) — Aspire SDK version 9.5.2 is in the AppHost csproj despite referencing Aspire.Hosting packages at 13.x
- **DB migrations**: Separate `PersonalWeb.MigrationService` worker project (not EF CLI in prod); run migrations manually via `dotnet ef database update --project PersonalWeb.Api`

## Commands

### .NET (run from repo root)
```
dotnet build
dotnet test # all tests
dotnet test --filter "FullyQualifiedName~<TestName>" # single test
dotnet run --project PersonalWeb.AppHost # start Aspire (preferred dev entry)
dotnet ef database update --project PersonalWeb.Api # apply migrations
```

Integration tests (`PersonalWeb.Api.Integration.Test`) require a real PostgreSQL instance. The connection string is read from `ConnectionStrings__Default` env var (set in CI) or `appsettings.Development.json`.

### Frontend (must run from `PersonalWeb.Admin/`)
```
yarn install
yarn test # Jest with coverage
yarn test --testPathPattern=<pattern> # single test file
yarn build
yarn start
```

The AppHost auto-runs `yarn install` if `node_modules` is missing (via MSBuild `RestoreNpm` target). The frontend uses `react-scripts` (CRA), **not** Vite.

## Architecture

Two parallel API surface areas exist for every entity — **do not collapse them**:
- `Controllers/OData/` — OData controllers extending `DefaultController<TEntity, TRepository>` (inherits `ODataController`, uses `[EnableQuery]`, `Delta<T>` for PATCH, registered under route `/v1/`)
- `Controllers/` (non-OData) — REST controllers extending `DefaultRestController<TEntity, TRepository>` (inherits `ControllerBase`, route pattern `/api/[controller]/[action]`)

The frontend admin (`PersonalWeb.Admin`) talks to `/api` (non-OData REST via the OData data provider through a dev proxy), which is proxied to `/v1` of the API service. The proxy target is injected via Aspire's `services__apiservice__apiservice__0` env var in [`setupProxy.js`](PersonalWeb.Admin/src/setupProxy.js).

## Critical Patterns

### Models
- All entity models are `record` types extending `DefaultModel` (abstract record).
- `DefaultModel` implements both `IAuditable` and `IDraftable`.
- `IAuditable` mandates: `Id` (Guid), `CreateBy` (Required), `CreatedDate` (Required), `UpdatedBy`, `UpdatedDate`, `IsDeleted`, `DeletedBy`, `DeletedDate`.
- Soft-delete is enforced: `GetAll()` always filters `!x.IsDeleted`. Hard deletes do not exist — `Delete()` sets `IsDeleted = true`.

### Repositories
- All repositories extend `DefaultModelRepositories<TEntity, TContext>` and implement `IDefaultModelRepositories<TEntity>`.
- Concrete repository classes (e.g., `BlogRepositories`) inherit `DefaultModelRepositories` with `PersonalWebApiContext`; they contain no additional logic unless overridden.
- Throw `EntityNotFoundException` (not 404 directly) when an entity is not found — the exception is defined in `PersonalWeb.Api/Exceptions/`.

### Integration Tests
- Use `WebApplicationFactory<Program>` — requires the `public partial class Program { }` declaration at the bottom of [`Program.cs`](PersonalWeb.Api/Program.cs).
- Each test class implementing `IDisposable` cleans up via `ExecuteDelete()` on the DbSet in `Dispose()`.
- The `ODataListResponse<T>` wrapper type used in integration tests lives in `PersonalWeb.Api.Integration.Test/Models/`.

### Frontend Services
- Each entity has a dedicated file in `PersonalWeb.Admin/src/services/` exporting `List`, `Show`, `Edit`, `Create` components for react-admin.
- The OData data provider is initialized asynchronously in `App.tsx`; the app renders `<Loading>` until the provider resolves.

## Code Style

### C#
- Namespace per folder, matching project name prefix (`PersonalWeb.Api.Controllers`, etc.)
- `ImplicitUsings` and `Nullable` are enabled only in `PersonalWeb.AppHost` and `PersonalWeb.MigrationService` — **not** in `PersonalWeb.Api`; explicit `using` statements are required there.
- `[Required]` + `[MaxLength]` data annotations on model properties for validation (enforced by OData controller's `ModelState.IsValid` check).

### TypeScript/React
- Strict TypeScript (`noImplicitAny`, `strictNullChecks`, `noImplicitReturns`).
- `tsconfig.json` targets `es5` / `commonjs` (Jest compatibility), but the project `"type": "module"` is set in `package.json`.
- Jest uses `ts-jest` for `.ts/.tsx` and `babel-jest` for `.js/.jsx`; CSS imports are mocked via `identity-obj-proxy` (must be a dev dependency if added).
- axios module must be mapped to `axios/dist/node/axios.cjs` in Jest config (already set in [`jest.config.ts`](PersonalWeb.Admin/jest.config.ts)).
3 changes: 2 additions & 1 deletion PersonalWeb.Admin/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
/coverage

# production
/build
build/*
dist/*

# misc
.DS_Store
Expand Down
4 changes: 2 additions & 2 deletions PersonalWeb.Admin/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ RUN yarn build
FROM nginx:stable-alpine as run
COPY --from=build /app/default.conf.template /etc/nginx/conf.d/default.conf.template
COPY --from=build /app/nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/build /usr/share/nginx/html
CMD /bin/sh -c "envsubst '\$PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf" && nginx -g 'daemon off;'
COPY --from=build /app/dist /usr/share/nginx/html
CMD /bin/sh -c "envsubst '\$PORT \$API_URL' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf" && nginx -g 'daemon off;'
9 changes: 0 additions & 9 deletions PersonalWeb.Admin/babel.config.js

This file was deleted.

17 changes: 17 additions & 0 deletions PersonalWeb.Admin/default.conf.template
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
server {
listen $PORT default_server;

location /api/auth/ {
proxy_pass $API_URL/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /api/ {
proxy_pass $API_URL/v1/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
18 changes: 18 additions & 0 deletions PersonalWeb.Admin/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="/manifest.json">
<link rel="shortcut icon" href="/favicon.ico">
<title>Bervianto Leo Web Admin</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
25 changes: 0 additions & 25 deletions PersonalWeb.Admin/jest.config.ts

This file was deleted.

34 changes: 17 additions & 17 deletions PersonalWeb.Admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,35 @@
"type": "module",
"dependencies": {
"axios": "^1.20.0",
"http-proxy-middleware": "^4.2.0",
"prop-types": "^15.8.1",
"ra-data-odata-server": "^5.0.0",
"ra-input-rich-text": "^5.15.3",
"react": "^19.3.0",
"react-admin": "^5.15.3",
"react-dom": "^19.3.0",
"react-scripts": "^5.0.1"
"ra-input-rich-text": "^5.15.4",
"react": "^19.3.1",
"react-admin": "^5.15.4",
"react-dom": "^19.3.1"
},
"devDependencies": {
"@babel/preset-env": "^8.0.5",
"@types/jest": "^30.0.0",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.20.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"babel-jest": "^30.5.1",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/coverage-v8": "^3.2.4",
"happy-dom": "^21.0.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.5.1",
"jest-environment-jsdom": "^29.7.0",
"ts-jest": "^29.4.12",
"jsdom": "^26.1.0",
"ts-node": "^10.9.2",
"typescript": "^6.0.3",
"util": "^0.12.5"
"util": "^0.12.5",
"vite": "^6.3.5",
"vitest": "^3.2.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "jest",
"eject": "react-scripts eject"
"start": "vite",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"browserslist": [
">0.2%",
Expand Down
40 changes: 0 additions & 40 deletions PersonalWeb.Admin/public/index.html

This file was deleted.

2 changes: 0 additions & 2 deletions PersonalWeb.Admin/setup.jest.js

This file was deleted.

8 changes: 8 additions & 0 deletions PersonalWeb.Admin/setup.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// globalSetup runs in the Node worker BEFORE the test environment is created.
// This ensures TextEncoder/TextDecoder from Node's 'util' are on the global
// before react-router loads its own encoding modules.
import { TextEncoder, TextDecoder } from 'util';

export function setup() {
Object.assign(global, { TextEncoder, TextDecoder });
}
Loading
Loading