diff --git a/docs/superpowers/plans/2026-05-14-vertical-slice-notifications-pilot.md b/docs/superpowers/plans/2026-05-14-vertical-slice-notifications-pilot.md new file mode 100644 index 00000000..60d70b87 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-vertical-slice-notifications-pilot.md @@ -0,0 +1,1304 @@ +# Vertical-Slice Feature Folders — Notifications Pilot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the reusable folder-migration tooling (Phase 0) and convert the Notifications module to the vertical-slice feature-folder shape end-to-end (Phase 1), proving the pattern before any other module is touched. + +**Architecture:** A Node script (`scripts/feature-folder-migrate.mjs`) consumes a per-module TSV manifest of `OLD_PATHNEW_PATH` pairs, performs `git mv` for each pair, and rewrites the `namespace ...;` declaration in each moved `.cs` file based on the project root and the new path. Call-site `using` updates are handled manually by following compiler errors after each move batch (small per-module blast radius — Notifications has zero cross-module `using` consumers). The Notifications module's runtime contract `NotificationService` becomes a `public sealed partial class` whose root fragment (constructor + `db` field) lives at `Infrastructure/NotificationService.cs` and whose five operation methods each live next to their endpoint at `Features/Notifications//NotificationService..cs`. + +**Tech Stack:** .NET 10, EF Core, xUnit.v3, FluentAssertions, Node 22+ (for migration script), git. + +**Spec:** `docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md`. Authority on conventions C1–C8 and on what is/isn't in scope. + +--- + +## Pre-flight check + +Before starting Task 1, confirm you are in the worktree at `/root/github/SimpleModule/.claude/worktrees/explore-feature-folders` on branch `worktree-explore-feature-folders` and that the spec commit (`6b975b75`) is present: + +```bash +git rev-parse --abbrev-ref HEAD +# Expected: worktree-explore-feature-folders +git log --oneline -3 +# Expected to include: 6b975b75 docs: design spec for vertical-slice feature folders refactor +``` + +If either check fails, stop and reconcile before continuing. + +--- + +## File structure + +### New files this plan creates + +| Path | Purpose | +|---|---| +| `scripts/feature-folder-migrate.mjs` | Migration tool: reads manifest, performs `git mv` + namespace rewrite | +| `scripts/feature-folder-migrate.test.mjs` | Node `node:test` suite for the migrate tool | +| `scripts/manifests/notifications.tsv` | Move manifest for the Notifications pilot | +| `modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/NotificationService.List.cs` | New partial-class fragment | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/UnreadCountEndpoint.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/NotificationService.UnreadCount.cs` | New partial-class fragment | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/MarkReadEndpoint.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/NotificationService.MarkRead.cs` | New partial-class fragment | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/MarkAllReadEndpoint.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/NotificationService.MarkAllRead.cs` | New partial-class fragment | +| `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/GetById/NotificationService.GetById.cs` | New partial-class fragment (no endpoint — cross-module contract method only) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsDbContext.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs` | Root partial (was `Services/NotificationService.cs`; bodies extracted) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Notifier.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsLog.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/*` (5 files) | (move targets) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/EntityConfigurations/NotificationConfiguration.cs` | (move target) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Jobs/DispatchNotificationJob.cs` | (move target) | +| `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/List/ListAsyncTests.cs` | Split from `Unit/NotificationServiceTests.cs` | +| `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/UnreadCount/GetUnreadCountAsyncTests.cs` | Split from `Unit/NotificationServiceTests.cs` | +| `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkRead/MarkReadAsyncTests.cs` | Split from `Unit/NotificationServiceTests.cs` | +| `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkAllRead/MarkAllReadAsyncTests.cs` | Split from `Unit/NotificationServiceTests.cs` | +| `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/NotificationServiceTestFixture.cs` | Shared in-memory DB + SeedAsync helper | + +### Files moved (also tracked by `notifications.tsv`) + +The TSV manifest in Task 4 is the authoritative source. The table above lists the *destinations*; sources are listed in the manifest. + +### Files explicitly NOT moved + +These stay where they are: + +- `modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs` +- `modules/Notifications/src/SimpleModule.Notifications/Pages/Inbox.tsx` +- `modules/Notifications/src/SimpleModule.Notifications/Pages/index.ts` +- `modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs` +- `modules/Notifications/src/SimpleModule.Notifications/NotificationsModuleOptions.cs` +- `modules/Notifications/src/SimpleModule.Notifications/NotificationsPermissions.cs` +- `modules/Notifications/src/SimpleModule.Notifications/types.ts`, `vite.config.ts` +- All entity / shared / event types in `SimpleModule.Notifications.Contracts/` root and `Events/` (per the pilot's bounded scope — entities reference EF migrations) +- `SimpleModule.Notifications.Tests/Unit/NotifierTests.cs`, `Unit/TestBackgroundJobs.cs` (cross-cutting tests stay in `Unit/`) + +--- + +## Task 1: Set up the migration-script test scaffold + +**Files:** +- Create: `scripts/feature-folder-migrate.test.mjs` + +The script's behaviour is small enough that a single Node test file using the built-in `node:test` runner is sufficient. Tests will exercise the pure functions before we add a CLI driver. + +- [ ] **Step 1: Create the failing test file** + +Create `scripts/feature-folder-migrate.test.mjs`: + +```javascript +// scripts/feature-folder-migrate.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deriveNamespace } from './feature-folder-migrate.mjs'; + +test('deriveNamespace: file at project root → assembly name only', () => { + const ns = deriveNamespace({ + assemblyName: 'SimpleModule.Notifications', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications', + filePath: 'modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs', + }); + assert.equal(ns, 'SimpleModule.Notifications'); +}); + +test('deriveNamespace: nested feature folder → dotted segments', () => { + const ns = deriveNamespace({ + assemblyName: 'SimpleModule.Notifications', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications', + filePath: + 'modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs', + }); + assert.equal(ns, 'SimpleModule.Notifications.Features.Notifications.List'); +}); + +test('deriveNamespace: Contracts assembly with Features subtree', () => { + const ns = deriveNamespace({ + assemblyName: 'SimpleModule.Notifications.Contracts', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications.Contracts', + filePath: + 'modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs', + }); + assert.equal(ns, 'SimpleModule.Notifications.Contracts.Features.Notifications.List'); +}); + +test('deriveNamespace: throws when filePath is outside projectRoot', () => { + assert.throws( + () => + deriveNamespace({ + assemblyName: 'SimpleModule.Notifications', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications', + filePath: 'modules/Other/SomeFile.cs', + }), + /not under project root/, + ); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `node --test scripts/feature-folder-migrate.test.mjs` + +Expected: All 4 tests FAIL with `Cannot find module './feature-folder-migrate.mjs'` (the script doesn't exist yet). + +- [ ] **Step 3: Commit the test scaffold** + +```bash +git add scripts/feature-folder-migrate.test.mjs +git commit -m "test: scaffold feature-folder-migrate.mjs test suite" +``` + +--- + +## Task 2: Implement `deriveNamespace` + +**Files:** +- Create: `scripts/feature-folder-migrate.mjs` + +- [ ] **Step 1: Write the minimal implementation** + +Create `scripts/feature-folder-migrate.mjs`: + +```javascript +// scripts/feature-folder-migrate.mjs +import { posix as path } from 'node:path'; + +/** + * Compute the C# namespace for a file based on its position relative to the + * project root, using the folder-equals-namespace convention. + * + * @param {object} args + * @param {string} args.assemblyName e.g. "SimpleModule.Notifications" + * @param {string} args.projectRoot path to the .csproj directory (forward-slash) + * @param {string} args.filePath path to the .cs file (forward-slash) + * @returns {string} the dotted namespace, e.g. "SimpleModule.Notifications.Features.Notifications.List" + */ +export function deriveNamespace({ assemblyName, projectRoot, filePath }) { + const normalizedRoot = path.normalize(projectRoot).replace(/\/+$/, '') + '/'; + const normalizedFile = path.normalize(filePath); + if (!normalizedFile.startsWith(normalizedRoot)) { + throw new Error( + `File ${filePath} is not under project root ${projectRoot}`, + ); + } + const relative = normalizedFile.slice(normalizedRoot.length); + const dir = path.dirname(relative); + if (dir === '.' || dir === '') { + return assemblyName; + } + const segments = dir.split('/').filter((s) => s.length > 0); + return [assemblyName, ...segments].join('.'); +} +``` + +- [ ] **Step 2: Run tests, confirm all 4 pass** + +Run: `node --test scripts/feature-folder-migrate.test.mjs` + +Expected output ends with `# pass 4` and `# fail 0`. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/feature-folder-migrate.mjs +git commit -m "feat(scripts): add deriveNamespace for feature-folder migrate tool" +``` + +--- + +## Task 3: Implement namespace rewriter + +**Files:** +- Modify: `scripts/feature-folder-migrate.mjs` +- Modify: `scripts/feature-folder-migrate.test.mjs` + +This step adds `rewriteNamespace(source, newNamespace)`: takes the original `.cs` source text and returns it with its `namespace ...;` declaration replaced. Only the file-scoped form (`namespace X.Y;` at top level) is supported — every module file in the repo uses this style per `.editorconfig`. + +- [ ] **Step 1: Add the failing tests** + +Append to `scripts/feature-folder-migrate.test.mjs`: + +```javascript +import { rewriteNamespace } from './feature-folder-migrate.mjs'; + +test('rewriteNamespace: replaces existing file-scoped namespace', () => { + const before = [ + 'using System;', + '', + 'namespace SimpleModule.Notifications.Endpoints.Notifications;', + '', + 'public class Foo {}', + ].join('\n'); + const after = rewriteNamespace(before, 'SimpleModule.Notifications.Features.Notifications.List'); + assert.match(after, /^namespace SimpleModule\.Notifications\.Features\.Notifications\.List;$/m); + assert.doesNotMatch(after, /Endpoints\.Notifications/); +}); + +test('rewriteNamespace: preserves trailing whitespace and other lines', () => { + const before = 'namespace A.B;\n\npublic class X {}\n'; + const after = rewriteNamespace(before, 'A.C'); + assert.equal(after, 'namespace A.C;\n\npublic class X {}\n'); +}); + +test('rewriteNamespace: no-op when target equals current', () => { + const before = 'namespace A.B;\n\npublic class X {}\n'; + const after = rewriteNamespace(before, 'A.B'); + assert.equal(after, before); +}); + +test('rewriteNamespace: throws on block-scoped namespace (unsupported)', () => { + const before = 'namespace A.B\n{\n public class X {}\n}\n'; + assert.throws( + () => rewriteNamespace(before, 'A.C'), + /file-scoped namespace declaration/, + ); +}); + +test('rewriteNamespace: throws when no namespace declaration found', () => { + const before = 'public class X {}\n'; + assert.throws(() => rewriteNamespace(before, 'A.B'), /no file-scoped namespace/); +}); +``` + +- [ ] **Step 2: Run tests, confirm the 5 new tests fail** + +Run: `node --test scripts/feature-folder-migrate.test.mjs` + +Expected: 4 passing (from Task 2) + 5 failing (`rewriteNamespace is not a function`). + +- [ ] **Step 3: Implement `rewriteNamespace`** + +Append to `scripts/feature-folder-migrate.mjs`: + +```javascript +const FILE_SCOPED_NS = /^namespace\s+([A-Za-z_][\w.]*)\s*;\s*$/m; +const BLOCK_SCOPED_NS = /^namespace\s+[A-Za-z_][\w.]*\s*\{/m; + +/** + * Replace the file-scoped namespace declaration in a .cs source string. + * Throws if the file uses a block-scoped namespace or has none at all. + */ +export function rewriteNamespace(source, newNamespace) { + const match = source.match(FILE_SCOPED_NS); + if (!match) { + if (BLOCK_SCOPED_NS.test(source)) { + throw new Error( + 'rewriteNamespace requires a file-scoped namespace declaration (got block-scoped)', + ); + } + throw new Error('rewriteNamespace: no file-scoped namespace declaration found'); + } + if (match[1] === newNamespace) { + return source; + } + return source.replace(FILE_SCOPED_NS, `namespace ${newNamespace};`); +} +``` + +- [ ] **Step 4: Run tests, confirm all 9 pass** + +Run: `node --test scripts/feature-folder-migrate.test.mjs` + +Expected: `# pass 9`, `# fail 0`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/feature-folder-migrate.mjs scripts/feature-folder-migrate.test.mjs +git commit -m "feat(scripts): add rewriteNamespace for feature-folder migrate tool" +``` + +--- + +## Task 4: Implement CLI driver and manifest parser + +**Files:** +- Modify: `scripts/feature-folder-migrate.mjs` +- Modify: `scripts/feature-folder-migrate.test.mjs` + +The CLI takes one argument — the path to a TSV manifest — and applies it. Manifest format: + +``` +# Lines starting with # are comments. Blank lines ignored. +# Three tab-separated columns: +# OLD_PATHNEW_PATHASSEMBLY_NAME[/PROJECT_ROOT] +# where ASSEMBLY_NAME is the C# assembly the file lives in and +# PROJECT_ROOT (after the slash) is the .csproj directory. +# If PROJECT_ROOT is omitted, it is inferred as the existing parent of OLD_PATH +# up to (and including) a directory matching ASSEMBLY_NAME. +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs\tmodules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs\tSimpleModule.Notifications +``` + +For each entry: +1. Create the destination directory if missing. +2. `git mv `. +3. If the file is `.cs`, read it, rewrite its namespace using `deriveNamespace` + `rewriteNamespace`, write it back, and `git add` the modified file. + +- [ ] **Step 1: Write failing test for `parseManifest`** + +Append to `scripts/feature-folder-migrate.test.mjs`: + +```javascript +import { parseManifest } from './feature-folder-migrate.mjs'; + +test('parseManifest: skips comments and blank lines, parses TSV rows', () => { + const input = [ + '# header comment', + '', + 'a/b.cs\ta/c/b.cs\tSimpleModule.X', + ' ', + 'foo.cs\tbar/foo.cs\tSimpleModule.Y\tmodules/Y/src/SimpleModule.Y', + ].join('\n'); + const rows = parseManifest(input); + assert.deepEqual(rows, [ + { oldPath: 'a/b.cs', newPath: 'a/c/b.cs', assemblyName: 'SimpleModule.X', projectRoot: undefined }, + { + oldPath: 'foo.cs', + newPath: 'bar/foo.cs', + assemblyName: 'SimpleModule.Y', + projectRoot: 'modules/Y/src/SimpleModule.Y', + }, + ]); +}); + +test('parseManifest: throws on malformed row (wrong column count)', () => { + assert.throws(() => parseManifest('only-one-column.cs\n'), /expected 3 or 4 tab-separated columns/); +}); +``` + +- [ ] **Step 2: Run tests, confirm 2 new failures** + +Run: `node --test scripts/feature-folder-migrate.test.mjs` + +Expected: `# pass 9`, `# fail 2` (`parseManifest is not a function`). + +- [ ] **Step 3: Implement `parseManifest` and the CLI** + +Append to `scripts/feature-folder-migrate.mjs`: + +```javascript +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * Parse a TSV manifest. Returns an array of move directives. + */ +export function parseManifest(text) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line, idx) => { + const cols = line.split('\t'); + if (cols.length !== 3 && cols.length !== 4) { + throw new Error( + `line ${idx + 1}: expected 3 or 4 tab-separated columns, got ${cols.length}`, + ); + } + return { + oldPath: cols[0], + newPath: cols[1], + assemblyName: cols[2], + projectRoot: cols[3], + }; + }); +} + +/** + * Infer the .csproj directory by walking up from oldPath until we find a folder + * named exactly assemblyName. + */ +function inferProjectRoot(oldPath, assemblyName) { + const parts = path.normalize(oldPath).split('/'); + for (let i = parts.length - 1; i > 0; i -= 1) { + if (parts[i] === assemblyName) { + return parts.slice(0, i + 1).join('/'); + } + } + throw new Error( + `cannot infer projectRoot for ${oldPath}; no path segment matches assembly ${assemblyName}`, + ); +} + +function applyMove(directive) { + const projectRoot = directive.projectRoot ?? inferProjectRoot(directive.oldPath, directive.assemblyName); + const newDir = path.dirname(directive.newPath); + mkdirSync(newDir, { recursive: true }); + execFileSync('git', ['mv', directive.oldPath, directive.newPath], { stdio: 'inherit' }); + + if (directive.newPath.endsWith('.cs')) { + const newNamespace = deriveNamespace({ + assemblyName: directive.assemblyName, + projectRoot, + filePath: directive.newPath, + }); + const source = readFileSync(directive.newPath, 'utf8'); + const rewritten = rewriteNamespace(source, newNamespace); + if (rewritten !== source) { + writeFileSync(directive.newPath, rewritten); + execFileSync('git', ['add', directive.newPath], { stdio: 'inherit' }); + } + } +} + +function main(argv) { + const manifestPath = argv[2]; + if (!manifestPath) { + console.error('Usage: node scripts/feature-folder-migrate.mjs '); + process.exit(2); + } + const text = readFileSync(manifestPath, 'utf8'); + const directives = parseManifest(text); + for (const d of directives) { + applyMove(d); + } + console.log(`Applied ${directives.length} move(s) from ${manifestPath}`); +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) { + main(process.argv); +} +``` + +- [ ] **Step 4: Run tests, confirm all 11 pass** + +Run: `node --test scripts/feature-folder-migrate.test.mjs` + +Expected: `# pass 11`, `# fail 0`. + +- [ ] **Step 5: Smoke-test the CLI on a tiny throwaway manifest** + +Create a temporary fixture and dry-run, then delete it. From the worktree root: + +```bash +mkdir -p /tmp/ffm-smoke/src/SimpleModule.Smoke +cat > /tmp/ffm-smoke/src/SimpleModule.Smoke/Foo.cs <<'EOF' +namespace SimpleModule.Smoke; + +public class Foo {} +EOF +cd /tmp/ffm-smoke && git init -q && git add . && git -c user.email=a@b -c user.name=a commit -q -m init +printf 'src/SimpleModule.Smoke/Foo.cs\tsrc/SimpleModule.Smoke/Features/Bar/Foo.cs\tSimpleModule.Smoke\n' > manifest.tsv +node /root/github/SimpleModule/.claude/worktrees/explore-feature-folders/scripts/feature-folder-migrate.mjs manifest.tsv +cat src/SimpleModule.Smoke/Features/Bar/Foo.cs +``` + +Expected: the file is moved to `Features/Bar/Foo.cs` and its namespace is now `namespace SimpleModule.Smoke.Features.Bar;`. Clean up: `cd / && rm -rf /tmp/ffm-smoke` and `cd` back to the worktree. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/feature-folder-migrate.mjs scripts/feature-folder-migrate.test.mjs +git commit -m "feat(scripts): add CLI driver and manifest parser for feature-folder migrate" +``` + +--- + +## Task 5: Write the Notifications manifest + +**Files:** +- Create: `scripts/manifests/notifications.tsv` + +This task only creates the manifest. Application happens in Tasks 6 and 7, split by concern so each commit is reviewable. + +- [ ] **Step 1: Create `scripts/manifests/` and write the manifest** + +```bash +mkdir -p scripts/manifests +``` + +Create `scripts/manifests/notifications.tsv` (use literal tabs between columns — most editors expand on save; if yours doesn't, use `printf '%s\t%s\t%s\n'` to generate lines): + +``` +# Notifications pilot — feature-folder migration manifest. +# Columns: OLD_PATHNEW_PATHASSEMBLY_NAME + +# --- Infrastructure moves (impl project) --- +modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsDbContext.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Notifier.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsLog.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannelRegistry.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/DatabaseChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/MailChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/LogSmsChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/EntityConfigurations/NotificationConfiguration.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Jobs/DispatchNotificationJob.cs SimpleModule.Notifications + +# --- Feature moves: endpoints (impl project) --- +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/UnreadCountEndpoint.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/MarkReadEndpoint.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/MarkAllReadEndpoint.cs SimpleModule.Notifications + +# --- Feature-scoped DTO move (contracts project) --- +modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs SimpleModule.Notifications.Contracts +``` + +> **Pilot scope note** (also enforced by what's *not* in this manifest): entity types (`Notification`, `NotificationId`, `NotificationRecipient`) and channel-payload types (`MailMessage`, `SmsMessage`, `DatabaseNotificationPayload`, `INotification`) stay at `Contracts/` root because they are referenced by full namespace in `template/SimpleModule.Host/Migrations/*` snapshots. Moving them would force migration regeneration, which is out of scope for the pilot. Revisit once the pattern is proven and we have a migration-rewrite story. + +- [ ] **Step 2: Commit the manifest** + +```bash +git add scripts/manifests/notifications.tsv +git commit -m "chore(notifications): add feature-folder migration manifest" +``` + +--- + +## Task 6: Apply manifest — Infrastructure moves only + +**Files:** +- Modify (via `git mv` + namespace rewrite): the 11 Infrastructure rows of `notifications.tsv` +- Modify: all files containing `using SimpleModule.Notifications.Channels`, `using SimpleModule.Notifications.Endpoints.Notifications`, `using SimpleModule.Notifications.EntityConfigurations`, `using SimpleModule.Notifications.Jobs`, `using SimpleModule.Notifications.Services`, and the bare module-root namespace where appropriate. + +This task moves only the Infrastructure rows (rows 1–11) of the manifest. Features and Contracts come in Task 7. This is split to keep the diff readable and the failure surface smaller. + +- [ ] **Step 1: Apply Infrastructure rows** + +Create a temporary partial manifest with only the Infrastructure rows, run the migrate tool, then delete the partial: + +```bash +sed -n '/# --- Infrastructure moves/,/# --- Feature moves: endpoints/p' scripts/manifests/notifications.tsv \ + | grep -v '^#' | grep -v '^$' > /tmp/notifications-infra.tsv +node scripts/feature-folder-migrate.mjs /tmp/notifications-infra.tsv +rm /tmp/notifications-infra.tsv +``` + +Expected: 11 files moved, 11 namespace rewrites, all staged with `git add`. + +- [ ] **Step 2: Verify the build fails as expected** + +Run: `dotnet build modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj` + +Expected: compilation FAILS with `CS0234` errors (`The type or namespace name 'X' does not exist in the namespace 'SimpleModule.Notifications.'`) and/or `CS0246` (type not found). These come from `NotificationsModule.cs` and the four endpoint files still using the old `Services`/`Channels`/`Jobs` namespaces. + +This failure confirms the rewrite tool didn't silently miss anything — it moved 11 files and the consumers haven't been updated yet. + +- [ ] **Step 3: Fix `using` statements in consumers** + +Update `using` statements across the Notifications module so the new namespaces resolve. The following table lists every consumer file and the exact `using` line edits: + +| File | Remove `using` | Add `using` | +|---|---|---| +| `modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs` | `using SimpleModule.Notifications.Channels;` `using SimpleModule.Notifications.Jobs;` `using SimpleModule.Notifications.Services;` | `using SimpleModule.Notifications.Infrastructure;` `using SimpleModule.Notifications.Infrastructure.Channels;` `using SimpleModule.Notifications.Infrastructure.Jobs;` | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/DatabaseChannel.cs` | (none — file moved, internal references stay valid since they reference Contracts types) | (none; verify build) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/MailChannel.cs` | (none) | (none; verify build) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/LogSmsChannel.cs` | (none) | (none; verify build) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannel.cs` | (none) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannelRegistry.cs` | (none) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs` | (none — references its own DbContext via Contracts, which didn't move) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Notifier.cs` | If it referenced `Channels` types in its `using`s, replace `using SimpleModule.Notifications.Channels;` with `using SimpleModule.Notifications.Infrastructure.Channels;` | (as needed) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsLog.cs` | (none expected) | (none expected) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsDbContext.cs` | If references `EntityConfigurations` namespace, replace with `Infrastructure.EntityConfigurations` | (as needed) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Jobs/DispatchNotificationJob.cs` | If references `Channels` or `Services`, update to `Infrastructure.Channels` / `Infrastructure` | (as needed) | +| `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/EntityConfigurations/NotificationConfiguration.cs` | (none — uses Contracts types) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs` | (none — endpoints still reference contract interface; service is injected, not used by type) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs` | (none) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs` | (none) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs` | (none) | (none) | +| `modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs` | (none) | (none) | +| `modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs` | `using SimpleModule.Notifications.Services;` | `using SimpleModule.Notifications.Infrastructure;` | +| `modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs` | `using SimpleModule.Notifications.Services;` (if present) | `using SimpleModule.Notifications.Infrastructure;` | + +For each "as needed" row above, open the file, read its `using` list, and apply the substitution mechanically. **Do not change anything else.** Use search-and-replace at the directive level (find exact `using` lines, not types). + +- [ ] **Step 4: Re-run the build** + +Run: `dotnet build modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj` + +Expected: 0 errors, 0 warnings (warnings would also fail given `TreatWarningsAsErrors=true`). + +- [ ] **Step 5: Run the test project** + +Run: `dotnet test modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj` + +Expected: all tests pass — at least the 7 in `NotificationServiceTests`, plus whatever is in `NotifierTests.cs` and `TestBackgroundJobs.cs`. Confirm count matches pre-migration baseline (capture by running once before Task 6 if not already known). + +- [ ] **Step 6: Run the full solution build** + +Run: `dotnet build` (from the worktree root). + +Expected: 0 errors. This validates that no other module accidentally referenced the moved namespaces. + +- [ ] **Step 7: Commit** + +```bash +git add modules/Notifications/ +git commit -m "refactor(notifications): move infrastructure files into Infrastructure/ subfolder + +Move NotificationsDbContext, NotificationService, Notifier, NotificationsLog, +Channels/*, EntityConfigurations/*, and Jobs/* into Infrastructure/ to start +the feature-folder migration. Update internal using statements; no public +contract changes. See spec: docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md" +``` + +--- + +## Task 7: Apply manifest — Features moves + Contracts move + +**Files:** +- Modify (via `git mv` + namespace rewrite): the Features and Contracts rows of `notifications.tsv` +- Modify: any consumer that now needs an extra `using` for the new Features namespaces. + +- [ ] **Step 1: Apply the remaining rows** + +```bash +sed -n '/# --- Feature moves/,$p' scripts/manifests/notifications.tsv \ + | grep -v '^#' | grep -v '^$' > /tmp/notifications-features.tsv +node scripts/feature-folder-migrate.mjs /tmp/notifications-features.tsv +rm /tmp/notifications-features.tsv +``` + +Expected: 5 files moved (4 endpoints + 1 contracts request), 5 namespace rewrites. + +- [ ] **Step 2: Verify build fails with expected breaks** + +Run: `dotnet build` + +Expected: compilation FAILS because `QueryNotificationsRequest` is now in `SimpleModule.Notifications.Contracts.Features.Notifications.List` but consumers in the Notifications module and host still import only `SimpleModule.Notifications.Contracts`. + +Compilation errors are expected in (at minimum): `Infrastructure/NotificationService.cs`, `Features/Notifications/List/ListNotificationsEndpoint.cs`, `Pages/InboxEndpoint.cs`, `NotificationServiceTests.cs`. + +- [ ] **Step 3: Add the new `using` to consumers of `QueryNotificationsRequest`** + +In each file that uses `QueryNotificationsRequest`, add a `using` for the new namespace **after** the existing `using SimpleModule.Notifications.Contracts;`: + +```diff + using SimpleModule.Notifications.Contracts; ++using SimpleModule.Notifications.Contracts.Features.Notifications.List; +``` + +Files needing this edit (verify via `grep -rln "QueryNotificationsRequest" modules/Notifications/ template/SimpleModule.Host/`): + +- `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs` +- `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs` (the file was just moved; the `using` for its own namespace neighbour is automatic, but it consumes `QueryNotificationsRequest` from Contracts so the new `using` is required) +- `modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs` +- `modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs` + +The endpoint files for `UnreadCount`, `MarkRead`, `MarkAllRead` do NOT use `QueryNotificationsRequest` and need no edits. + +- [ ] **Step 4: Re-run the build** + +Run: `dotnet build` + +Expected: 0 errors. + +- [ ] **Step 5: Run all tests** + +Run: `dotnet test modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj` + +Expected: all tests pass — same count as after Task 6. + +- [ ] **Step 6: Commit** + +```bash +git add modules/Notifications/ +git commit -m "refactor(notifications): move endpoints to Features/ and slice Contracts + +Endpoints/Notifications/* moved to Features/Notifications//. The feature- +scoped QueryNotificationsRequest moved to Contracts/Features/Notifications/List/. +Entity, value-object, and channel-payload contracts remain at Contracts root +to avoid migration-snapshot churn (revisit in a follow-up phase)." +``` + +--- + +## Task 8: Split `NotificationService` into partial-class fragments + +**Files:** +- Modify: `modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs` (becomes the root partial — drops methods, keeps constructor) +- Create: `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/NotificationService.List.cs` +- Create: `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/NotificationService.UnreadCount.cs` +- Create: `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/NotificationService.MarkRead.cs` +- Create: `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/NotificationService.MarkAllRead.cs` +- Create: `modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/GetById/NotificationService.GetById.cs` + +The class becomes `public sealed partial class NotificationService`. Each operation method moves to its feature folder. The root partial declares the primary constructor `(NotificationsDbContext db)` and the implements clause; the fragments add methods only. + +- [ ] **Step 1: Rewrite `Infrastructure/NotificationService.cs` as the root partial** + +Replace the file content with: + +```csharp +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService(NotificationsDbContext db) : INotificationsContracts +{ +} +``` + +The `NotificationsDbContext db` primary constructor parameter is in scope for every partial fragment in the same class (C# 12 primary constructor semantics). + +> **Heads-up on namespaces:** partial-class fragments under `Features///` declare the *owning class's* namespace (the `Infrastructure/` namespace where the root partial lives), **not** the folder-derived namespace. C# requires every partial declaration of the same class to share a namespace; otherwise they become distinct types and SM0025 fires. This is a deliberate exception to spec Convention C1; the file's *folder* identifies the slice, and the *namespace* identifies the type. Task 11 amends the spec to record this. Every `NotificationService..cs` fragment below uses `namespace SimpleModule.Notifications.Infrastructure;`. + +- [ ] **Step 2: Create `Features/Notifications/List/NotificationService.List.cs`** + +```csharp +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Features.Notifications.List; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task> ListAsync( + UserId userId, + QueryNotificationsRequest request + ) + { + var query = db.Notifications.AsNoTracking().Where(n => n.UserId == userId); + + if (request.UnreadOnly == true) + { + query = query.Where(n => n.ReadAt == null); + } + if (!string.IsNullOrWhiteSpace(request.Channel)) + { + query = query.Where(n => n.Channel == request.Channel); + } + if (!string.IsNullOrWhiteSpace(request.Type)) + { + query = query.Where(n => n.Type == request.Type); + } + + var totalCount = await query.CountAsync(); + var page = request.EffectivePage; + var pageSize = request.EffectivePageSize; + + var items = await query + .OrderByDescending(n => n.CreatedAt) + .ThenByDescending(n => n.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + }; + } +} +``` + +- [ ] **Step 3: Create the remaining 4 fragments** + +`Features/Notifications/UnreadCount/NotificationService.UnreadCount.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public Task GetUnreadCountAsync( + UserId userId, + CancellationToken cancellationToken = default + ) => + db.Notifications.CountAsync( + n => n.UserId == userId && n.ReadAt == null, + cancellationToken + ); +} +``` + +`Features/Notifications/GetById/NotificationService.GetById.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task GetByIdAsync(NotificationId id, UserId userId) => + await db.Notifications.AsNoTracking().FirstOrDefaultAsync(n => + n.Id == id && n.UserId == userId + ); +} +``` + +`Features/Notifications/MarkRead/NotificationService.MarkRead.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task MarkReadAsync(NotificationId id, UserId userId) + { + var notification = await db.Notifications.FirstOrDefaultAsync(n => + n.Id == id && n.UserId == userId + ); + if (notification is null) + { + return false; + } + + if (notification.ReadAt is not null) + { + return true; + } + + notification.ReadAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return true; + } +} +``` + +`Features/Notifications/MarkAllRead/NotificationService.MarkAllRead.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task MarkAllReadAsync(UserId userId) + { + var now = DateTimeOffset.UtcNow; + return await db + .Notifications.Where(n => n.UserId == userId && n.ReadAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, now)); + } +} +``` + +- [ ] **Step 4: Build** + +Run: `dotnet build` + +Expected: 0 errors. If the source generator complains that `INotificationsContracts` is not fully implemented (SM0025 or CS0535), one of the partial fragments has a typo in its method signature or wrong namespace. Verify each fragment compiles before moving on. + +- [ ] **Step 5: Run tests** + +Run: `dotnet test modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj` + +Expected: same test count as before, all passing. The partial-class split is a pure refactor — no behaviour change. + +- [ ] **Step 6: Commit** + +```bash +git add modules/Notifications/ +git commit -m "refactor(notifications): split NotificationService into per-feature partials + +NotificationService becomes a sealed partial class. Each cross-module contract +method (ListAsync, GetUnreadCountAsync, GetByIdAsync, MarkReadAsync, +MarkAllReadAsync) moves to a fragment file co-located with its feature folder. +The root partial in Infrastructure/ retains the constructor and implements +clause. Partial fragments deliberately keep the owning class's namespace +(Infrastructure) rather than matching folder; their *folder* identifies +the slice." +``` + +--- + +## Task 9: Split `NotificationServiceTests` into per-feature test files + +**Files:** +- Modify: `modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs` → delete after extracting +- Create: `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/NotificationServiceTestFixture.cs` +- Create: `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/List/ListAsyncTests.cs` +- Create: `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/UnreadCount/GetUnreadCountAsyncTests.cs` +- Create: `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkRead/MarkReadAsyncTests.cs` +- Create: `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkAllRead/MarkAllReadAsyncTests.cs` + +The original `NotificationServiceTests.cs` has 7 tests across 4 operations. The shared in-memory DbContext setup and `SeedAsync` helper move into a `NotificationServiceTestFixture` base class. Each operation's tests become a small file. + +- [ ] **Step 1: Create the shared fixture** + +Create `modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/NotificationServiceTestFixture.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Infrastructure; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications; + +public abstract class NotificationServiceTestFixture : IDisposable +{ + protected readonly NotificationsDbContext Db; + protected readonly NotificationService Sut; + protected readonly UserId CurrentUserId = UserId.From("user-1"); + + protected NotificationServiceTestFixture() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + var dbOptions = Options.Create( + new DatabaseOptions + { + ModuleConnections = new Dictionary + { + ["Notifications"] = "Data Source=:memory:", + }, + } + ); + Db = new NotificationsDbContext(options, dbOptions); + Db.Database.OpenConnection(); + Db.Database.EnsureCreated(); + Sut = new NotificationService(Db); + } + + public void Dispose() + { + Db.Dispose(); + GC.SuppressFinalize(this); + } + + protected async Task SeedAsync(UserId? userId = null, DateTimeOffset? readAt = null) + { + var n = new Notification + { + Id = NotificationId.From(Guid.CreateVersion7()), + UserId = userId ?? CurrentUserId, + Type = "test.event", + Channel = NotificationsConstants.Channels.Database, + Title = "Title", + Body = "Body", + DataJson = "{}", + ReadAt = readAt, + }; + Db.Notifications.Add(n); + await Db.SaveChangesAsync(); + return n; + } +} +``` + +- [ ] **Step 2: Create `ListAsyncTests.cs`** + +```csharp +using FluentAssertions; +using SimpleModule.Notifications.Contracts.Features.Notifications.List; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.List; + +public sealed class ListAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task ListAsync_ReturnsOnlyOwnNotifications() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(userId: UserId.From("other-user")); + + var result = await Sut.ListAsync(CurrentUserId, new QueryNotificationsRequest()); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + } + + [Fact] + public async Task ListAsync_UnreadOnly_FiltersReadNotifications() + { + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var result = await Sut.ListAsync( + CurrentUserId, + new QueryNotificationsRequest { UnreadOnly = true } + ); + + result.TotalCount.Should().Be(1); + } +} +``` + +- [ ] **Step 3: Create `GetUnreadCountAsyncTests.cs`** + +```csharp +using FluentAssertions; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.UnreadCount; + +public sealed class GetUnreadCountAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task GetUnreadCountAsync_ReturnsUnreadOnly() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var count = await Sut.GetUnreadCountAsync(CurrentUserId); + + count.Should().Be(2); + } +} +``` + +- [ ] **Step 4: Create `MarkReadAsyncTests.cs`** + +```csharp +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.MarkRead; + +public sealed class MarkReadAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task MarkReadAsync_SetsReadAt() + { + var n = await SeedAsync(); + + var result = await Sut.MarkReadAsync(n.Id, CurrentUserId); + + result.Should().BeTrue(); + var refreshed = await Db.Notifications.AsNoTracking().FirstAsync(x => x.Id == n.Id); + refreshed.ReadAt.Should().NotBeNull(); + } + + [Fact] + public async Task MarkReadAsync_WithDifferentUser_ReturnsFalse() + { + var n = await SeedAsync(); + + var result = await Sut.MarkReadAsync(n.Id, UserId.From("not-the-owner")); + + result.Should().BeFalse(); + } +} +``` + +- [ ] **Step 5: Create `MarkAllReadAsyncTests.cs`** + +```csharp +using FluentAssertions; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.MarkAllRead; + +public sealed class MarkAllReadAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task MarkAllReadAsync_MarksAllUnreadForUser() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + await SeedAsync(userId: UserId.From("other")); + + var marked = await Sut.MarkAllReadAsync(CurrentUserId); + + marked.Should().Be(2); + var remainingUnread = await Sut.GetUnreadCountAsync(CurrentUserId); + remainingUnread.Should().Be(0); + var otherUserUnread = await Sut.GetUnreadCountAsync(UserId.From("other")); + otherUserUnread.Should().Be(1); + } +} +``` + +- [ ] **Step 6: Delete the original combined test file** + +```bash +git rm modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs +``` + +- [ ] **Step 7: Build and test** + +Run: `dotnet build` + +Expected: 0 errors. + +Run: `dotnet test modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj` + +Expected: all 7 `NotificationService*` tests pass (now spread across 4 files) plus the unchanged `NotifierTests.cs` and `TestBackgroundJobs.cs`. + +- [ ] **Step 8: Commit** + +```bash +git add modules/Notifications/ +git commit -m "test(notifications): split NotificationServiceTests by feature + +Replace Unit/NotificationServiceTests.cs with per-feature files under +Features/Notifications//, sharing a NotificationServiceTestFixture base. +Cross-cutting tests (NotifierTests, TestBackgroundJobs) remain in Unit/." +``` + +--- + +## Task 10: Full pilot verification + +**Files:** (no edits — this task only runs commands) + +Validate the pilot end-to-end: build, test, frontend build, page-registry validation, and CI parity. + +- [ ] **Step 1: Capture pre-pilot baselines (if not already captured)** + +If a baseline wasn't captured before Task 6, capture one now from `main` by stashing the worktree's changes into a temp branch and checking out main in a separate clone. **Skip this step if the baseline counts are already known** — it exists only to make the comparison concrete in Step 4. + +- [ ] **Step 2: Build the entire solution** + +Run: `dotnet build` + +Expected: 0 errors, 0 warnings. + +- [ ] **Step 3: Run all .NET tests** + +Run: `dotnet test` + +Expected: all tests pass, count >= pre-pilot baseline (we added test files but didn't remove behavioural coverage). + +- [ ] **Step 4: Validate the page registry** + +Run: `npm run validate-pages` + +Expected: exit code 0, no mismatches reported. (The Inbox page wasn't moved, so this should be a no-op — but we verify it explicitly because the script is the canonical check for SM0042/IViewEndpoint correctness.) + +- [ ] **Step 5: Build the frontend** + +Run: `npm run build:dev` + +Expected: every workspace builds cleanly, including `@simplemodule/notifications`. + +- [ ] **Step 6: Lint and format check** + +Run: `npm run check` + +Expected: 0 issues. + +- [ ] **Step 7: Run the CI skill locally (if available)** + +If the `ci` skill is installed, invoke it: it mirrors the GitHub Actions pipeline. + +Expected: green across all CI steps. + +- [ ] **Step 8: Inspect the source-generator output for Notifications** + +Run: + +```bash +dotnet build modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj /p:EmitCompilerGeneratedFiles=true /p:CompilerGeneratedFilesOutputPath=obj/generated +grep -l "NotificationsModule\|INotificationsContracts" \ + modules/Notifications/src/SimpleModule.Notifications/obj/generated/**/*.cs 2>/dev/null || true +``` + +Expected: the generated files list the 4 endpoint classes and the 1 view endpoint by their **new** namespaces (`SimpleModule.Notifications.Features.Notifications.List.ListNotificationsEndpoint`, etc.). If any old `Endpoints.Notifications` reference appears, the generator is caching stale paths — clean `bin`/`obj` and rebuild. + +- [ ] **Step 9: Smoke-test the running app (optional but recommended)** + +If a dev environment is available: + +```bash +dotnet run --project template/SimpleModule.Host +``` + +In a browser, hit `/notifications` (the Inbox view) and confirm: page loads, an unread count is shown, and at least one of `GET /api/notifications/`, `POST /api/notifications/{id}/read`, `POST /api/notifications/read-all`, `GET /api/notifications/unread-count` returns a non-error status in network inspector. + +Skip this step if no test data exists; the unit/integration tests in Step 3 cover the same endpoints. + +- [ ] **Step 10: Commit the verification log (if anything was tweaked)** + +If Steps 2–9 surfaced any issue and a fix was applied, commit it now with a precise message. If no changes were needed, skip the commit — the pilot is verified. + +--- + +## Task 11: Document the pilot finding and amend the spec + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md` (Convention C1 + C2 clarification) +- Create: `tasks/lessons.md` entry under a new "Vertical-slice pilot" heading (or append if file exists) + +The Task 8 step uncovered a real conflict between C1 (folder = namespace) and C2 (partial-class split): a partial fragment's namespace must match the root partial's namespace, regardless of folder. The spec needs to record this exception so future module migrations don't trip over it. + +- [ ] **Step 1: Amend Convention C1 in the spec** + +In `docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md`, locate Convention C1 and append: + +```markdown +**Exception:** partial-class fragments under `Features///..cs` declare the *owning class's* namespace (the `Infrastructure/` namespace where the root partial lives), not the folder-derived namespace. The folder identifies the slice; the namespace identifies the type. This is required by C# — a partial declaration in a different namespace is a different type. +``` + +- [ ] **Step 2: Add a cross-reference to C2** + +Locate Convention C2 and append a final sentence: + +```markdown +See the C1 exception: fragment namespaces match the owning class, not the folder. +``` + +- [ ] **Step 3: Append a pilot-results entry to `tasks/lessons.md`** + +If `tasks/lessons.md` doesn't exist, create it. Append: + +```markdown +## Vertical-slice feature-folder pilot (Notifications, 2026-05-14) + +- Folder-equals-namespace conflicts with partial-class fragments. Resolved: fragments keep the owning class's namespace; folder is purely organizational. Spec C1 amended. +- EF migration snapshots reference contract types by full namespace. Moving entity/value-object types into `Contracts/Shared/` would force migration regeneration — out of scope for the pilot. Future phases must decide: keep entities at Contracts root forever, or accept migration churn. +- Cross-module `using SimpleModule.Notifications.Contracts;` count: zero. Per-module Contracts reshape is internal-only for this module. +- `npm run validate-pages` is a no-op for modules where `Pages/` is untouched. Still worth running in CI as the canonical gate. +- Migration script approach (TSV manifest + `git mv` + namespace rewrite) worked cleanly. ~16 moves, two small batches per commit. No surprises from the source generator. +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md tasks/lessons.md +git commit -m "docs: amend feature-folder spec C1 with partial-class exception + log pilot lessons" +``` + +--- + +## Exit criteria + +The pilot is **successful** when **all** of the following hold simultaneously: + +1. `dotnet build` from the worktree root passes with 0 errors and 0 warnings. +2. `dotnet test` runs to completion with all tests passing, including the 7 split `NotificationService*` tests and the unchanged `NotifierTests` / `TestBackgroundJobs`. +3. `npm run build:dev` builds every workspace. +4. `npm run validate-pages` exits 0. +5. `npm run check` exits 0. +6. The generated source files (Step 8 of Task 10) reference endpoints by their **new** `Features.Notifications.` namespaces. +7. `git log --oneline worktree-explore-feature-folders ^main` shows ~10 commits with clean, scoped messages and no `wip` / `fixup` clutter. +8. The Notifications module's `src/SimpleModule.Notifications/` directory has no remaining `Endpoints/`, `Services/`, `Channels/`, `EntityConfigurations/`, or `Jobs/` folders — those names exist only under `Infrastructure/` (Channels, EntityConfigurations, Jobs) or are renamed to `Features/`. +9. Spec C1 carries the partial-class exception note; `tasks/lessons.md` carries the pilot summary. + +If any of (1)–(6) fails, the pilot is **not** ready to propagate. Diagnose the failure, fix it, and update this plan (or the spec) with the lesson before declaring success. + +If (7) is cluttered, do an interactive rebase BEFORE handing off — but only if explicitly asked. By default leave the commit history as written. + +--- + +## Out of scope (for follow-up plans) + +- Converting any other module to the new shape — each gets its own plan after the pilot succeeds. +- Moving entity types or shared value objects into `Contracts/Shared/`. Requires an EF-migration story. +- Updating the `sm new module` / `sm new feature` CLI scaffolds. +- Adding any SM diagnostic to enforce the new layout. +- Reshaping `Pages/` (view endpoints + React) — explicitly out per Convention C4. diff --git a/docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md b/docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md new file mode 100644 index 00000000..54c29de8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-vertical-slice-feature-folders-design.md @@ -0,0 +1,230 @@ +# Vertical-Slice Feature Folders + +**Status:** Design approved 2026-05-14. Pending implementation plan. +**Owner:** Anto Subash (@subashjanto) +**Spec date:** 2026-05-14 + +## Problem + +The SimpleModule codebase is already substantially feature-organized at the module level: every module is a feature, `Endpoints//` groups API operations by aggregate, and `Pages/
/{*.tsx,*Endpoint.cs}` co-locates IViewEndpoints with their React components. However, three soft spots remain: + +1. **Type-based folders inside modules** — `Services/`, `EntityConfigurations/`, `Channels/`, `Jobs/`, and a sprinkling of cross-cutting files at module root (e.g. Users has 17 `*.cs` files in the project root: services + DbContext + module class + permissions + options). +2. **Operation logic is split between the endpoint file and a horizontal service.** Endpoints are skinny shims that call into a contract-implementing service (e.g. `UserAdminService.CreateUserAsync`). The endpoint, its DTO, its validator (when present), and the actual implementation method live in three or four different folders. +3. **Inconsistent validator placement.** Email puts FluentValidation validators in `Validators/` at module root; RateLimiting and Tenants already co-locate validators with their endpoints (`Endpoints/Policies/CreateRequestValidator.cs`). The codebase is drifting toward co-location organically; no rule guides it. + +The goal: organize each module so that **everything needed to read, modify, or test one operation lives in a single folder**, while honoring the framework's hard constraints (one contract → one implementation class per SM0025/SM0026; one endpoint per file per SM0049; entity classes in Contracts assemblies per SM0055). + +## Non-Goals + +- Changing the source generator or its diagnostics. +- Replacing the IEndpoint/IViewEndpoint abstractions with MediatR or similar. +- Restructuring the host app (`template/SimpleModule.Host`) or the frontend packages (`packages/`). +- Moving Pages/ (the view-side `IViewEndpoint` + React file pair) — they are already vertical and Vite's entry point binds to `Pages/index.ts`. Cost > benefit. +- Cross-module reorganization. Modules remain independent units of work. +- Adding new SM diagnostics to enforce the convention. Soft convention to start; we can add a diagnostic later if we see drift. + +## Approach + +A "feature folder" is the smallest unit that fully describes one API operation. For an operation `` on aggregate `` in module ``, the canonical contents are: + +| File | Role | Required? | +|---|---|---| +| `Endpoint.cs` | The `IEndpoint` implementation (route + handler) | yes | +| `Request.cs` (in Contracts) | Request DTO consumed by the endpoint | yes if the operation accepts a body | +| `Validator.cs` | FluentValidation rules for the request | required if the operation already has a validator today; optional for new operations (add when validation rules exceed what data annotations naturally express) | +| `..cs` | Partial-class fragment with the method body (e.g. `UserAdminService.Create.cs` declaring `CreateUserAsync`) | yes, one fragment per service the operation extends — see C2 for the multi-service case | + +These pieces sit in `Features///` on the impl side, mirrored by `Contracts/Features///Request.cs` on the contracts side. + +The cross-module contract implementation (e.g. `UserAdminService`) becomes a **partial class** whose root fragment lives in `Infrastructure/.cs` (constructor, fields, helpers shared across operations) and whose per-operation method bodies live next to their endpoints. The class remains a single type at runtime, so SM0025 (one implementation per contract) and SM0026 (no duplicate impls) stay green. + +### Reference layout: Users module + +``` +modules/Users/ +├── src/ +│ ├── SimpleModule.Users.Contracts/ +│ │ ├── Features/ +│ │ │ ├── Users/ +│ │ │ │ ├── Create/CreateUserRequest.cs +│ │ │ │ ├── Update/UpdateUserRequest.cs +│ │ │ │ └── Delete/ (no request body) +│ │ │ ├── AdminUsers/ +│ │ │ │ ├── Create/CreateAdminUserRequest.cs +│ │ │ │ └── Update/UpdateAdminUserRequest.cs +│ │ │ └── Account/ +│ │ ├── Events/ # unchanged — cross-module events +│ │ │ ├── UserCreatedEvent.cs +│ │ │ └── ... +│ │ ├── Shared/ # DTOs/types used by ≥2 features OR another module +│ │ │ ├── UserDto.cs +│ │ │ ├── AdminUserDto.cs +│ │ │ ├── RoleDto.cs +│ │ │ ├── UserId.cs +│ │ │ ├── UsersConstants.cs +│ │ │ └── Constants/ +│ │ │ ├── ConfigKeys.cs +│ │ │ ├── PersonalDataKeys.cs +│ │ │ └── SeedConstants.cs +│ │ ├── ApplicationUser.cs # EF entities stay at root (SM0055) +│ │ ├── ApplicationRole.cs +│ │ ├── IUserContracts.cs # contract interfaces at root +│ │ ├── IUserAdminContracts.cs +│ │ ├── IRoleAdminContracts.cs +│ │ └── IAccountUnlockEmailSender.cs +│ │ +│ └── SimpleModule.Users/ +│ ├── Features/ +│ │ ├── Users/ +│ │ │ ├── Create/ +│ │ │ │ ├── CreateEndpoint.cs +│ │ │ │ ├── CreateUserValidator.cs +│ │ │ │ └── UserAdminService.Create.cs +│ │ │ ├── Update/ +│ │ │ │ ├── UpdateEndpoint.cs +│ │ │ │ ├── UpdateUserValidator.cs +│ │ │ │ └── UserAdminService.Update.cs +│ │ │ ├── Delete/ +│ │ │ │ ├── DeleteEndpoint.cs +│ │ │ │ └── UserAdminService.Delete.cs +│ │ │ ├── GetAll/ +│ │ │ │ ├── GetAllEndpoint.cs +│ │ │ │ └── UserAdminService.GetAll.cs +│ │ │ ├── GetById/ +│ │ │ │ ├── GetByIdEndpoint.cs +│ │ │ │ └── UserAdminService.GetById.cs +│ │ │ ├── GetCurrent/ +│ │ │ │ ├── GetCurrentEndpoint.cs +│ │ │ │ └── UserService.GetCurrent.cs +│ │ │ └── DownloadPersonalData/ +│ │ │ ├── DownloadPersonalDataEndpoint.cs +│ │ │ └── UserService.DownloadPersonalData.cs +│ │ ├── Passkeys/ +│ │ │ ├── Get/GetPasskeysEndpoint.cs + UserService.GetPasskeys.cs +│ │ │ ├── Delete/DeletePasskeyEndpoint.cs + ... +│ │ │ ├── LoginBegin/... +│ │ │ ├── LoginComplete/... +│ │ │ ├── RegisterBegin/... +│ │ │ ├── RegisterComplete/... +│ │ │ └── PasskeyHelpers.cs # shared inside the Passkeys vertical +│ │ └── Account/ +│ │ └── Security/ +│ │ └── AccountSecurityEndpoint.cs +│ ├── Pages/ # UNCHANGED — IViewEndpoint + React stay here +│ │ ├── Account/ +│ │ │ ├── LoginEndpoint.cs + Login.tsx +│ │ │ ├── Manage/... +│ │ │ └── ... +│ │ └── index.ts # registry +│ ├── Infrastructure/ +│ │ ├── UsersDbContext.cs +│ │ ├── UserAdminService.cs # partial root (ctor, fields, shared helpers) +│ │ ├── UserService.cs # same pattern +│ │ ├── RoleAdminService.cs +│ │ ├── ApplyUsersModuleOptions.cs +│ │ ├── ApplySecurityStampValidatorOptions.cs +│ │ └── Services/ +│ │ ├── UserSeedService.cs +│ │ ├── ConsoleEmailSender.cs +│ │ └── ConsoleAccountUnlockEmailSender.cs +│ ├── components/ # shared React (unchanged) +│ ├── Locales/keys.ts # unchanged +│ ├── types.ts # unchanged +│ ├── vite.config.ts # unchanged +│ ├── UsersModule.cs # IModule at root +│ ├── UsersModuleOptions.cs +│ └── UsersPermissions.cs # IModulePermissions at root +│ +└── tests/ + └── SimpleModule.Users.Tests/ + ├── Features/ # mirrors impl tree + │ ├── Users/Create/CreateUserTests.cs + │ ├── Users/Update/UpdateUserTests.cs + │ └── Passkeys/... + ├── Unit/ # cross-cutting (UserIdTests, etc.) + └── Integration/ # cross-feature flows (e.g. AccountUnlock end-to-end) +``` + +### Conventions + +**C1. Folder = namespace.** A file at `Features/Users/Create/CreateEndpoint.cs` declares `namespace SimpleModule.Users.Features.Users.Create;`. This matches the existing project convention (folders map to namespaces) so no `.editorconfig` rules change. + +**Exception (partial-class fragments).** Files under `Features///..cs` declare the *owning class's* namespace (the `Infrastructure/` namespace where the root partial lives), not the folder-derived namespace. C# requires every partial declaration of the same class to share a namespace; otherwise they become distinct types and `SM0025` fires. The folder identifies the slice; the namespace identifies the type. + +**C2. Partial-class split.** Each service that implements a cross-module contract is a `public sealed partial class`. The root partial lives in `Infrastructure/` and owns the constructor, private fields, and any helper methods called by ≥2 operations. Each `..cs` fragment under a feature folder declares the same partial and adds **only** the public method for that operation (and any private helpers used by that operation alone). Field declarations and constructors live in **exactly one** fragment — the root. + +**Multi-service case:** if one feature legitimately touches two services (e.g. `Users/Create/` calls both `UserAdminService` and an event-emitting helper on `UserService`), the feature folder holds a fragment per service: `Users/Create/UserAdminService.Create.cs` *and* `Users/Create/UserService.OnCreate.cs`. Keep this rare — usually the operation belongs to a single service, and cross-service orchestration happens via DI inside the endpoint or via events. + +See the C1 exception: fragment namespaces match the owning class, not the folder. + +**C3. Shared vs feature DTO rule.** A request DTO lives in its feature folder until a second consumer (another feature or another module) appears. At that point it moves to `Contracts/Shared/`. Heuristic at refactor time: if the file is referenced by exactly one endpoint, it's a feature DTO; otherwise it's shared. + +**C4. Pages/ is intentionally untouched.** View endpoints and their React companions stay in `Pages/
/{*Endpoint.cs,*.tsx}`. Moving them would require rewriting `vite.config.ts` entry paths and every `Pages/index.ts` dynamic import, with no readability gain since they're already co-located. + +**C5. Tests mirror impl folders.** `tests/.Tests/Features///Tests.cs`. Tests remain in their own project (production assemblies stay xUnit-free). `Unit/` and `Integration/` keep their roles for cross-feature concerns. Shared fixture or helper types used by multiple operations within one aggregate live at the aggregate folder level (`tests/.Tests/Features//.cs`), not inside any single `/` folder. + +**C6. Validators stay co-located.** FluentValidation `AbstractValidator` files sit in the feature folder, named `Validator.cs` to match the request type they validate. Modules that currently park validators in a top-level `Validators/` folder (Email today) move them to feature folders during migration. + +**C7. Module-level files stay at impl-project root.** `Module.cs` (IModule), `ModuleOptions.cs`, `Permissions.cs`, `types.ts`, `vite.config.ts`. These are *not* feature-specific. + +**C8. EF entity configurations go to `Infrastructure/EntityConfigurations/`.** They map persistence shape, not feature behavior. They stay grouped because the DbContext references all of them at once during `OnModelCreating`. + +## Migration plan + +**Phase 0 — design & tooling (this spec + small follow-ups).** +- Land this design doc. +- Specify a migration script: `git mv` per file + `sed`-style namespace rewrite for the moved files. Use the script to keep history (git follows renames automatically). Optionally `dotnet format` after each phase. + +**Phase 1 — Notifications pilot.** Notifications is the smallest non-trivial module: 4 API endpoints (`ListNotifications`, `MarkAllRead`, `MarkRead`, `UnreadCount`), 1 view page (`Inbox`), 1 service (`NotificationService`) plus helpers (`NotificationsLog`, `Notifier`), 1 channel registry, 1 background job, 1 DbContext. Convert end-to-end. Validates: +- Source generator endpoint/permission/contract discovery survives the folder move (we expect yes — the generator works off types and assemblies, not folders, per CONSTITUTION.md §11). +- Partial-class split compiles and `SM0025`/`SM0026` stay green. +- `Pages/index.ts`, `npm run validate-pages`, and Vite entry points are untouched and still pass. +- Test discovery and CI (`ci` skill / GitHub Actions) pass after the rename. + +**Phase 2 — Settings.** Medium module with 3 resource groups (`Menus`, `Settings`, `UserSettings`), shared services (`SettingsService`, `PublicMenuService`), and 3 view pages. Confirms the pattern scales beyond trivial. + +**Phase 3 — propagate.** One module per PR, dependency-leaf-first to limit blast radius if anything breaks: +1. AuditLogs +2. FeatureFlags +3. Localization +4. FileStorage +5. Email (consolidate the `Validators/` folder into features) +6. BackgroundJobs +7. Permissions +8. OpenIddict +9. Tenants +10. RateLimiting +11. Dashboard +12. Admin +13. Users (largest; last to absorb lessons from earlier conversions) + +**Phase 4 — tooling and docs.** +- Update the `sm new module` and `sm new feature` CLI scaffolds to emit the new shape. +- Add a section to `docs/CONSTITUTION.md` §6 (or a new §6.1) describing the feature-folder convention. +- Consider but defer: a soft `Info`-level SM diagnostic that flags new code added outside `Features/`. + +## Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Source generator misses types after a folder move | Phase 1 pilot is the smoke test. Run `dotnet build` and verify generated `MapModuleEndpoints` still lists every endpoint. | +| Partial-class fragment forgets `using` for a type the root partial uses | Compiler error; quick fix. Each fragment is self-contained. | +| Method body uses a private helper defined in a different fragment's same partial | Allowed in C# (privates are visible across all partials of the same type in the same assembly). | +| Inconsistent validator placement across modules (Email under `Validators/`, Tenants/RateLimiting already co-locate next to endpoints) | The pattern already works in the co-locating modules; migration consolidates everyone on it. Email's `Validators/` folder is dissolved into feature folders during Phase 3. | +| Inconsistent operation naming (e.g. `Create` vs `CreateUser` vs `Add`) | Migration script preserves existing names; we don't rename operations mid-flight. Convention for *new* features: verb-noun (`CreateUser`, `MarkRead`). | +| Tests that span multiple endpoints (e.g. `UsersEndpointTests.cs`) | Decide per file at migration time. If the file tests one cohesive use case, keep as-is in `Integration/`. If it bundles unrelated tests, split during the Phase. | +| Phase 1 reveals the pattern doesn't work | Pilot is one module; rollback is one revert. | + +## Open questions (resolved during plan-writing) + +- **Operation folder naming**: PascalCase verb-only (`Create/`) vs verb+noun (`CreateUser/`)? Decision: **PascalCase verb-only** when the aggregate is already implied by the parent folder (`Features/Users/Create/`); verb+noun only when no aggregate group exists. +- **Should each operation always get a folder, or only when ≥2 files belong together?** Decision: **always a folder**. Even a one-file operation (`Delete/DeleteEndpoint.cs`) gets its own folder for consistency and to make adding a validator later a non-event. +- **Migration script vs manual moves**: write a small bash/PowerShell script that takes a manifest of moves and applies `git mv` + namespace rewrite. Targeted in the implementation plan. + +## Success criteria + +1. Every module's source code physically groups by feature, with module-root files limited to `Module.cs`, `ModuleOptions.cs`, `Permissions.cs`, and frontend config (`types.ts`, `vite.config.ts`). +2. `dotnet build` is clean across the solution after each Phase. `npm run check`, `npm run build`, `npm run validate-pages`, and `dotnet test` all pass. +3. No new SM diagnostics fire; no existing diagnostics are suppressed. +4. The `sm new feature` CLI generates the new shape for new operations. +5. CONSTITUTION.md documents the convention. diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs similarity index 84% rename from modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs rename to modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs index 043a525c..f2affb96 100644 --- a/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs @@ -1,6 +1,6 @@ using SimpleModule.Core; -namespace SimpleModule.Notifications.Contracts; +namespace SimpleModule.Notifications.Contracts.Features.Notifications.List; [Dto] public class QueryNotificationsRequest diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs index c732bd03..e9317542 100644 --- a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs @@ -1,4 +1,5 @@ using SimpleModule.Core; +using SimpleModule.Notifications.Contracts.Features.Notifications.List; using SimpleModule.Users.Contracts; namespace SimpleModule.Notifications.Contracts; diff --git a/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/GetById/NotificationService.GetById.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/GetById/NotificationService.GetById.cs new file mode 100644 index 00000000..4c4fa14f --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/GetById/NotificationService.GetById.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task GetByIdAsync(NotificationId id, UserId userId) => + await db + .Notifications.AsNoTracking() + .FirstOrDefaultAsync(n => n.Id == id && n.UserId == userId); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs similarity index 76% rename from modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs rename to modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs index 4a40ed93..ce890464 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs @@ -5,9 +5,10 @@ using SimpleModule.Core.Authorization; using SimpleModule.Core.Extensions; using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Features.Notifications.List; using SimpleModule.Users.Contracts; -namespace SimpleModule.Notifications.Endpoints.Notifications; +namespace SimpleModule.Notifications.Features.Notifications.List; public class ListNotificationsEndpoint : IEndpoint { @@ -20,8 +21,7 @@ public void Map(IEndpointRouteBuilder app) => [AsParameters] QueryNotificationsRequest request, HttpContext context, INotificationsContracts notifications - ) => - notifications.ListAsync(UserId.From(context.User.GetUserId()!), request) + ) => notifications.ListAsync(UserId.From(context.User.GetUserId()!), request) ) .RequirePermission(NotificationsPermissions.ViewOwn); } diff --git a/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/NotificationService.List.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/NotificationService.List.cs new file mode 100644 index 00000000..33a443ee --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/NotificationService.List.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Features.Notifications.List; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task> ListAsync( + UserId userId, + QueryNotificationsRequest request + ) + { + var query = db.Notifications.AsNoTracking().Where(n => n.UserId == userId); + + if (request.UnreadOnly == true) + { + query = query.Where(n => n.ReadAt == null); + } + if (!string.IsNullOrWhiteSpace(request.Channel)) + { + query = query.Where(n => n.Channel == request.Channel); + } + if (!string.IsNullOrWhiteSpace(request.Type)) + { + query = query.Where(n => n.Type == request.Type); + } + + var totalCount = await query.CountAsync(); + var page = request.EffectivePage; + var pageSize = request.EffectivePageSize; + + var items = await query + .OrderByDescending(n => n.CreatedAt) + .ThenByDescending(n => n.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + }; + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/MarkAllReadEndpoint.cs similarity index 92% rename from modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs rename to modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/MarkAllReadEndpoint.cs index e9ad5a37..f885e1f3 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/MarkAllReadEndpoint.cs @@ -7,7 +7,7 @@ using SimpleModule.Notifications.Contracts; using SimpleModule.Users.Contracts; -namespace SimpleModule.Notifications.Endpoints.Notifications; +namespace SimpleModule.Notifications.Features.Notifications.MarkAllRead; public class MarkAllReadEndpoint : IEndpoint { diff --git a/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/NotificationService.MarkAllRead.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/NotificationService.MarkAllRead.cs new file mode 100644 index 00000000..bd11eeb3 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/NotificationService.MarkAllRead.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task MarkAllReadAsync(UserId userId) + { + var now = DateTimeOffset.UtcNow; + return await db + .Notifications.Where(n => n.UserId == userId && n.ReadAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, now)); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/MarkReadEndpoint.cs similarity index 94% rename from modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs rename to modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/MarkReadEndpoint.cs index 185dd49e..c4c0bb6d 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/MarkReadEndpoint.cs @@ -7,7 +7,7 @@ using SimpleModule.Notifications.Contracts; using SimpleModule.Users.Contracts; -namespace SimpleModule.Notifications.Endpoints.Notifications; +namespace SimpleModule.Notifications.Features.Notifications.MarkRead; public class MarkReadEndpoint : IEndpoint { diff --git a/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/NotificationService.MarkRead.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/NotificationService.MarkRead.cs new file mode 100644 index 00000000..47358ae0 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/NotificationService.MarkRead.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public async Task MarkReadAsync(NotificationId id, UserId userId) + { + var notification = await db.Notifications.FirstOrDefaultAsync(n => + n.Id == id && n.UserId == userId + ); + if (notification is null) + { + return false; + } + + if (notification.ReadAt is not null) + { + return true; + } + + notification.ReadAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return true; + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/NotificationService.UnreadCount.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/NotificationService.UnreadCount.cs new file mode 100644 index 00000000..56aa5fd9 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/NotificationService.UnreadCount.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService +{ + public Task GetUnreadCountAsync( + UserId userId, + CancellationToken cancellationToken = default + ) => + db.Notifications.CountAsync(n => n.UserId == userId && n.ReadAt == null, cancellationToken); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/UnreadCountEndpoint.cs similarity index 93% rename from modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs rename to modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/UnreadCountEndpoint.cs index 82097d2d..83cddaa8 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/UnreadCountEndpoint.cs @@ -7,7 +7,7 @@ using SimpleModule.Notifications.Contracts; using SimpleModule.Users.Contracts; -namespace SimpleModule.Notifications.Endpoints.Notifications; +namespace SimpleModule.Notifications.Features.Notifications.UnreadCount; public class UnreadCountEndpoint : IEndpoint { diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/DatabaseChannel.cs similarity index 97% rename from modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/DatabaseChannel.cs index e236b145..156b5a85 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/DatabaseChannel.cs @@ -4,7 +4,7 @@ using SimpleModule.Notifications.Contracts.Events; using Wolverine.EntityFrameworkCore; -namespace SimpleModule.Notifications.Channels; +namespace SimpleModule.Notifications.Infrastructure.Channels; public partial class DatabaseChannel( NotificationsDbContext db, diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannel.cs similarity index 94% rename from modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannel.cs index ec96f708..ca8ba9fe 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannel.cs @@ -1,6 +1,6 @@ using SimpleModule.Notifications.Contracts; -namespace SimpleModule.Notifications.Channels; +namespace SimpleModule.Notifications.Infrastructure.Channels; /// /// A delivery channel — mail, database, sms, slack, push, etc. Each channel diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannelRegistry.cs similarity index 92% rename from modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannelRegistry.cs index 8a1dd495..36068cb4 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannelRegistry.cs @@ -1,6 +1,6 @@ using SimpleModule.Notifications.Contracts; -namespace SimpleModule.Notifications.Channels; +namespace SimpleModule.Notifications.Infrastructure.Channels; public interface INotificationChannelRegistry { diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/LogSmsChannel.cs similarity index 96% rename from modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/LogSmsChannel.cs index 28eadab3..83107bdf 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/LogSmsChannel.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Logging; using SimpleModule.Notifications.Contracts; -namespace SimpleModule.Notifications.Channels; +namespace SimpleModule.Notifications.Infrastructure.Channels; /// /// Default SMS channel: writes the message to the log. A real provider (Twilio, etc.) diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/MailChannel.cs similarity index 96% rename from modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/MailChannel.cs index 4d94b476..9acc3e5d 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/MailChannel.cs @@ -2,7 +2,7 @@ using SimpleModule.Email.Contracts; using SimpleModule.Notifications.Contracts; -namespace SimpleModule.Notifications.Channels; +namespace SimpleModule.Notifications.Infrastructure.Channels; /// /// Forwards a notification's mail payload to the Email module. Skips silently when the diff --git a/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/EntityConfigurations/NotificationConfiguration.cs similarity index 84% rename from modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/EntityConfigurations/NotificationConfiguration.cs index 3b59c640..50499ef9 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/EntityConfigurations/NotificationConfiguration.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using SimpleModule.Notifications.Contracts; -namespace SimpleModule.Notifications.EntityConfigurations; +namespace SimpleModule.Notifications.Infrastructure.EntityConfigurations; public class NotificationConfiguration : IEntityTypeConfiguration { @@ -20,7 +20,12 @@ public void Configure(EntityTypeBuilder builder) // Covers the inbox list query (filter UserId + order CreatedAt DESC, Id as tiebreaker) // and the unread-count query (predicate on UserId + ReadAt). - builder.HasIndex(n => new { n.UserId, n.CreatedAt, n.Id }); + builder.HasIndex(n => new + { + n.UserId, + n.CreatedAt, + n.Id, + }); builder.HasIndex(n => new { n.UserId, n.ReadAt }); } } diff --git a/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Jobs/DispatchNotificationJob.cs similarity index 95% rename from modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Jobs/DispatchNotificationJob.cs index ebbe7a74..43e49554 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Jobs/DispatchNotificationJob.cs @@ -1,14 +1,13 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Notifications.Channels; using SimpleModule.Notifications.Contracts; using SimpleModule.Notifications.Contracts.Events; -using SimpleModule.Notifications.Services; +using SimpleModule.Notifications.Infrastructure.Channels; using SimpleModule.Users.Contracts; using Wolverine; -namespace SimpleModule.Notifications.Jobs; +namespace SimpleModule.Notifications.Infrastructure.Jobs; public sealed record DispatchNotificationJobData( string UserId, diff --git a/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs new file mode 100644 index 00000000..42686a14 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs @@ -0,0 +1,6 @@ +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Infrastructure; + +public sealed partial class NotificationService(NotificationsDbContext db) + : INotificationsContracts { } diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsDbContext.cs similarity index 92% rename from modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsDbContext.cs index 7288cc8f..a3ba10ab 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsDbContext.cs @@ -3,10 +3,10 @@ using Microsoft.Extensions.Options; using SimpleModule.Database; using SimpleModule.Notifications.Contracts; -using SimpleModule.Notifications.EntityConfigurations; +using SimpleModule.Notifications.Infrastructure.EntityConfigurations; using SimpleModule.Users.Contracts; -namespace SimpleModule.Notifications; +namespace SimpleModule.Notifications.Infrastructure; public class NotificationsDbContext( DbContextOptions options, diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsLog.cs similarity index 92% rename from modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsLog.cs index 07d35d3d..392000e4 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsLog.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace SimpleModule.Notifications.Services; +namespace SimpleModule.Notifications.Infrastructure; internal static partial class NotificationsLog { diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Notifier.cs similarity index 94% rename from modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs rename to modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Notifier.cs index b7ae6201..7e623b99 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Notifier.cs @@ -1,12 +1,12 @@ using Microsoft.Extensions.Logging; using SimpleModule.BackgroundJobs.Contracts; -using SimpleModule.Notifications.Channels; using SimpleModule.Notifications.Contracts; using SimpleModule.Notifications.Contracts.Events; -using SimpleModule.Notifications.Jobs; +using SimpleModule.Notifications.Infrastructure.Channels; +using SimpleModule.Notifications.Infrastructure.Jobs; using Wolverine; -namespace SimpleModule.Notifications.Services; +namespace SimpleModule.Notifications.Infrastructure; public class Notifier( INotificationChannelRegistry channels, diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs index 1c0ea43f..64fea9bb 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs @@ -5,10 +5,10 @@ using SimpleModule.Core; using SimpleModule.Core.Settings; using SimpleModule.Database; -using SimpleModule.Notifications.Channels; using SimpleModule.Notifications.Contracts; -using SimpleModule.Notifications.Jobs; -using SimpleModule.Notifications.Services; +using SimpleModule.Notifications.Infrastructure; +using SimpleModule.Notifications.Infrastructure.Channels; +using SimpleModule.Notifications.Infrastructure.Jobs; namespace SimpleModule.Notifications; diff --git a/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs index e898d24a..274e2679 100644 --- a/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs +++ b/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs @@ -6,6 +6,7 @@ using SimpleModule.Core.Extensions; using SimpleModule.Core.Inertia; using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Features.Notifications.List; using SimpleModule.Users.Contracts; namespace SimpleModule.Notifications.Pages; diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs deleted file mode 100644 index a035d2e4..00000000 --- a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs +++ /dev/null @@ -1,91 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SimpleModule.Core; -using SimpleModule.Notifications.Contracts; -using SimpleModule.Users.Contracts; - -namespace SimpleModule.Notifications.Services; - -public class NotificationService(NotificationsDbContext db) : INotificationsContracts -{ - public async Task> ListAsync( - UserId userId, - QueryNotificationsRequest request - ) - { - var query = db.Notifications.AsNoTracking().Where(n => n.UserId == userId); - - if (request.UnreadOnly == true) - { - query = query.Where(n => n.ReadAt == null); - } - if (!string.IsNullOrWhiteSpace(request.Channel)) - { - query = query.Where(n => n.Channel == request.Channel); - } - if (!string.IsNullOrWhiteSpace(request.Type)) - { - query = query.Where(n => n.Type == request.Type); - } - - var totalCount = await query.CountAsync(); - var page = request.EffectivePage; - var pageSize = request.EffectivePageSize; - - var items = await query - .OrderByDescending(n => n.CreatedAt) - .ThenByDescending(n => n.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); - - return new PagedResult - { - Items = items, - TotalCount = totalCount, - Page = page, - PageSize = pageSize, - }; - } - - public Task GetUnreadCountAsync( - UserId userId, - CancellationToken cancellationToken = default - ) => - db.Notifications.CountAsync( - n => n.UserId == userId && n.ReadAt == null, - cancellationToken - ); - - public async Task GetByIdAsync(NotificationId id, UserId userId) => - await db.Notifications.AsNoTracking().FirstOrDefaultAsync(n => - n.Id == id && n.UserId == userId - ); - - public async Task MarkReadAsync(NotificationId id, UserId userId) - { - var notification = await db.Notifications.FirstOrDefaultAsync(n => - n.Id == id && n.UserId == userId - ); - if (notification is null) - { - return false; - } - - if (notification.ReadAt is not null) - { - return true; - } - - notification.ReadAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(); - return true; - } - - public async Task MarkAllReadAsync(UserId userId) - { - var now = DateTimeOffset.UtcNow; - return await db - .Notifications.Where(n => n.UserId == userId && n.ReadAt == null) - .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, now)); - } -} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/List/ListAsyncTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/List/ListAsyncTests.cs new file mode 100644 index 00000000..48af07dd --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/List/ListAsyncTests.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using SimpleModule.Notifications.Contracts.Features.Notifications.List; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.List; + +public sealed class ListAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task ListAsync_ReturnsOnlyOwnNotifications() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(userId: UserId.From("other-user")); + + var result = await Sut.ListAsync(CurrentUserId, new QueryNotificationsRequest()); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + } + + [Fact] + public async Task ListAsync_UnreadOnly_FiltersReadNotifications() + { + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var result = await Sut.ListAsync( + CurrentUserId, + new QueryNotificationsRequest { UnreadOnly = true } + ); + + result.TotalCount.Should().Be(1); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkAllRead/MarkAllReadAsyncTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkAllRead/MarkAllReadAsyncTests.cs new file mode 100644 index 00000000..9f804ba5 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkAllRead/MarkAllReadAsyncTests.cs @@ -0,0 +1,24 @@ +using FluentAssertions; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.MarkAllRead; + +public sealed class MarkAllReadAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task MarkAllReadAsync_MarksAllUnreadForUser() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + await SeedAsync(userId: UserId.From("other")); + + var marked = await Sut.MarkAllReadAsync(CurrentUserId); + + marked.Should().Be(2); + var remainingUnread = await Sut.GetUnreadCountAsync(CurrentUserId); + remainingUnread.Should().Be(0); + var otherUserUnread = await Sut.GetUnreadCountAsync(UserId.From("other")); + otherUserUnread.Should().Be(1); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkRead/MarkReadAsyncTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkRead/MarkReadAsyncTests.cs new file mode 100644 index 00000000..c83fab51 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/MarkRead/MarkReadAsyncTests.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.MarkRead; + +public sealed class MarkReadAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task MarkReadAsync_SetsReadAt() + { + var n = await SeedAsync(); + + var result = await Sut.MarkReadAsync(n.Id, CurrentUserId); + + result.Should().BeTrue(); + var refreshed = await Db.Notifications.AsNoTracking().FirstAsync(x => x.Id == n.Id); + refreshed.ReadAt.Should().NotBeNull(); + } + + [Fact] + public async Task MarkReadAsync_WithDifferentUser_ReturnsFalse() + { + var n = await SeedAsync(); + + var result = await Sut.MarkReadAsync(n.Id, UserId.From("not-the-owner")); + + result.Should().BeFalse(); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/NotificationServiceTestFixture.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/NotificationServiceTestFixture.cs new file mode 100644 index 00000000..6dbb5a95 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/NotificationServiceTestFixture.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Infrastructure; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Features.Notifications; + +public abstract class NotificationServiceTestFixture : IDisposable +{ + private readonly NotificationsDbContext _db; + private bool _disposed; + + protected NotificationsDbContext Db => _db; + protected NotificationService Sut { get; } + protected UserId CurrentUserId { get; } = UserId.From("user-1"); + + protected NotificationServiceTestFixture() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + var dbOptions = Options.Create( + new DatabaseOptions + { + ModuleConnections = new Dictionary + { + ["Notifications"] = "Data Source=:memory:", + }, + } + ); + _db = new NotificationsDbContext(options, dbOptions); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + Sut = new NotificationService(_db); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _db.Dispose(); + } + _disposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected async Task SeedAsync( + UserId? userId = null, + DateTimeOffset? readAt = null + ) + { + var n = new Notification + { + Id = NotificationId.From(Guid.CreateVersion7()), + UserId = userId ?? CurrentUserId, + Type = "test.event", + Channel = NotificationsConstants.Channels.Database, + Title = "Title", + Body = "Body", + DataJson = "{}", + ReadAt = readAt, + }; + _db.Notifications.Add(n); + await _db.SaveChangesAsync(); + return n; + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/UnreadCount/GetUnreadCountAsyncTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/UnreadCount/GetUnreadCountAsyncTests.cs new file mode 100644 index 00000000..22771380 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Features/Notifications/UnreadCount/GetUnreadCountAsyncTests.cs @@ -0,0 +1,18 @@ +using FluentAssertions; + +namespace SimpleModule.Notifications.Tests.Features.Notifications.UnreadCount; + +public sealed class GetUnreadCountAsyncTests : NotificationServiceTestFixture +{ + [Fact] + public async Task GetUnreadCountAsync_ReturnsUnreadOnly() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var count = await Sut.GetUnreadCountAsync(CurrentUserId); + + count.Should().Be(2); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs deleted file mode 100644 index bc744c8d..00000000 --- a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using SimpleModule.Database; -using SimpleModule.Notifications.Contracts; -using SimpleModule.Notifications.Services; -using SimpleModule.Users.Contracts; - -namespace SimpleModule.Notifications.Tests.Unit; - -public sealed class NotificationServiceTests : IDisposable -{ - private readonly NotificationsDbContext _db; - private readonly NotificationService _sut; - private readonly UserId _userId = UserId.From("user-1"); - - public NotificationServiceTests() - { - var options = new DbContextOptionsBuilder() - .UseSqlite("Data Source=:memory:") - .Options; - var dbOptions = Options.Create( - new DatabaseOptions - { - ModuleConnections = new Dictionary - { - ["Notifications"] = "Data Source=:memory:", - }, - } - ); - _db = new NotificationsDbContext(options, dbOptions); - _db.Database.OpenConnection(); - _db.Database.EnsureCreated(); - _sut = new NotificationService(_db); - } - - public void Dispose() => _db.Dispose(); - - private async Task SeedAsync(UserId? userId = null, DateTimeOffset? readAt = null) - { - var n = new Notification - { - Id = NotificationId.From(Guid.CreateVersion7()), - UserId = userId ?? _userId, - Type = "test.event", - Channel = NotificationsConstants.Channels.Database, - Title = "Title", - Body = "Body", - DataJson = "{}", - ReadAt = readAt, - }; - _db.Notifications.Add(n); - await _db.SaveChangesAsync(); - return n; - } - - [Fact] - public async Task ListAsync_ReturnsOnlyOwnNotifications() - { - await SeedAsync(); - await SeedAsync(); - await SeedAsync(userId: UserId.From("other-user")); - - var result = await _sut.ListAsync(_userId, new QueryNotificationsRequest()); - - result.Items.Should().HaveCount(2); - result.TotalCount.Should().Be(2); - } - - [Fact] - public async Task ListAsync_UnreadOnly_FiltersReadNotifications() - { - await SeedAsync(); - await SeedAsync(readAt: DateTimeOffset.UtcNow); - - var result = await _sut.ListAsync( - _userId, - new QueryNotificationsRequest { UnreadOnly = true } - ); - - result.TotalCount.Should().Be(1); - } - - [Fact] - public async Task GetUnreadCountAsync_ReturnsUnreadOnly() - { - await SeedAsync(); - await SeedAsync(); - await SeedAsync(readAt: DateTimeOffset.UtcNow); - - var count = await _sut.GetUnreadCountAsync(_userId); - - count.Should().Be(2); - } - - [Fact] - public async Task MarkReadAsync_SetsReadAt() - { - var n = await SeedAsync(); - - var result = await _sut.MarkReadAsync(n.Id, _userId); - - result.Should().BeTrue(); - var refreshed = await _db.Notifications.AsNoTracking().FirstAsync(x => x.Id == n.Id); - refreshed.ReadAt.Should().NotBeNull(); - } - - [Fact] - public async Task MarkReadAsync_WithDifferentUser_ReturnsFalse() - { - var n = await SeedAsync(); - - var result = await _sut.MarkReadAsync(n.Id, UserId.From("not-the-owner")); - - result.Should().BeFalse(); - } - - [Fact] - public async Task MarkAllReadAsync_MarksAllUnreadForUser() - { - await SeedAsync(); - await SeedAsync(); - await SeedAsync(readAt: DateTimeOffset.UtcNow); - await SeedAsync(userId: UserId.From("other")); - - var marked = await _sut.MarkAllReadAsync(_userId); - - marked.Should().Be(2); - var remainingUnread = await _sut.GetUnreadCountAsync(_userId); - remainingUnread.Should().Be(0); - var otherUserUnread = await _sut.GetUnreadCountAsync(UserId.From("other")); - otherUserUnread.Should().Be(1); - } -} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs index 2c3f49d2..7f451fa1 100644 --- a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs @@ -1,10 +1,10 @@ using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; -using SimpleModule.Notifications.Channels; using SimpleModule.Notifications.Contracts; -using SimpleModule.Notifications.Jobs; -using SimpleModule.Notifications.Services; +using SimpleModule.Notifications.Infrastructure; +using SimpleModule.Notifications.Infrastructure.Channels; +using SimpleModule.Notifications.Infrastructure.Jobs; using SimpleModule.Users.Contracts; using Wolverine; @@ -16,7 +16,7 @@ private sealed class CapturingChannel(string name) : INotificationChannel { public string Name { get; } = name; public List<(NotificationRecipient Recipient, INotification Notification)> Calls { get; } = - []; + []; public Task SendAsync( NotificationRecipient recipient, @@ -64,9 +64,10 @@ public async Task SendNowAsync_DispatchesToEachChannel() ); var recipient = new NotificationRecipient(UserId.From("u1"), "u1@test.com"); - var notification = new TestNotification( - [NotificationsConstants.Channels.Database, NotificationsConstants.Channels.Mail] - ); + var notification = new TestNotification([ + NotificationsConstants.Channels.Database, + NotificationsConstants.Channels.Mail, + ]); await sut.SendNowAsync(recipient, notification); @@ -88,9 +89,10 @@ public async Task SendNowAsync_UnknownChannelIsSkipped() var recipient = new NotificationRecipient(UserId.From("u1")); const string unregisteredChannel = "unregistered"; - var notification = new TestNotification( - [unregisteredChannel, NotificationsConstants.Channels.Database] - ); + var notification = new TestNotification([ + unregisteredChannel, + NotificationsConstants.Channels.Database, + ]); await sut.SendNowAsync(recipient, notification); @@ -135,13 +137,15 @@ public async Task SendAsync_EnqueuesOneJobPerChannel() ); var recipient = new NotificationRecipient(UserId.From("u1"), "u1@test.com"); - var notification = new TestNotification( - [NotificationsConstants.Channels.Database, NotificationsConstants.Channels.Mail] - ); + var notification = new TestNotification([ + NotificationsConstants.Channels.Database, + NotificationsConstants.Channels.Mail, + ]); await sut.SendAsync(recipient, notification); jobs.EnqueuedJobs.Should().HaveCount(2); - jobs.EnqueuedJobs.Should().AllSatisfy(j => j.JobType.Should().Be()); + jobs.EnqueuedJobs.Should() + .AllSatisfy(j => j.JobType.Should().Be()); } } diff --git a/scripts/feature-folder-migrate.mjs b/scripts/feature-folder-migrate.mjs new file mode 100644 index 00000000..acd7470e --- /dev/null +++ b/scripts/feature-folder-migrate.mjs @@ -0,0 +1,137 @@ +// scripts/feature-folder-migrate.mjs +import { posix as path } from 'node:path'; + +/** + * Compute the C# namespace for a file based on its position relative to the + * project root, using the folder-equals-namespace convention. + * + * @param {object} args + * @param {string} args.assemblyName e.g. "SimpleModule.Notifications" + * @param {string} args.projectRoot path to the .csproj directory (forward-slash) + * @param {string} args.filePath path to the .cs file (forward-slash) + * @returns {string} the dotted namespace, e.g. "SimpleModule.Notifications.Features.Notifications.List" + */ +export function deriveNamespace({ assemblyName, projectRoot, filePath }) { + const normalizedRoot = path.normalize(projectRoot).replace(/\/+$/, '') + '/'; + const normalizedFile = path.normalize(filePath); + if (!normalizedFile.startsWith(normalizedRoot)) { + throw new Error( + `File ${filePath} is not under project root ${projectRoot}`, + ); + } + const relative = normalizedFile.slice(normalizedRoot.length); + const dir = path.dirname(relative); + if (dir === '.' || dir === '') { + return assemblyName; + } + const segments = dir.split('/').filter((s) => s.length > 0); + return [assemblyName, ...segments].join('.'); +} + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const FILE_SCOPED_NS = /^namespace\s+([A-Za-z_][\w.]*)\s*;/m; +const BLOCK_SCOPED_NS = /^namespace\s+[A-Za-z_][\w.]*\s*\{/m; + +/** + * Replace the file-scoped namespace declaration in a .cs source string. + * Throws if the file uses a block-scoped namespace or has none at all. + */ +export function rewriteNamespace(source, newNamespace) { + const match = source.match(FILE_SCOPED_NS); + if (!match) { + if (BLOCK_SCOPED_NS.test(source)) { + throw new Error( + 'rewriteNamespace requires a file-scoped namespace declaration (got block-scoped)', + ); + } + throw new Error('rewriteNamespace: no file-scoped namespace declaration found'); + } + if (match[1] === newNamespace) { + return source; + } + return source.replace(FILE_SCOPED_NS, `namespace ${newNamespace};`); +} + +/** + * Parse a TSV manifest. Returns an array of move directives. + */ +export function parseManifest(text) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line, idx) => { + const cols = line.split('\t'); + if (cols.length !== 3 && cols.length !== 4) { + throw new Error( + `line ${idx + 1}: expected 3 or 4 tab-separated columns, got ${cols.length}`, + ); + } + return { + oldPath: cols[0], + newPath: cols[1], + assemblyName: cols[2], + projectRoot: cols[3], + }; + }); +} + +/** + * Infer the .csproj directory by walking up from oldPath until we find a folder + * named exactly assemblyName. + */ +function inferProjectRoot(oldPath, assemblyName) { + const parts = path.normalize(oldPath).split('/'); + for (let i = parts.length - 1; i > 0; i -= 1) { + if (parts[i] === assemblyName) { + return parts.slice(0, i + 1).join('/'); + } + } + throw new Error( + `cannot infer projectRoot for ${oldPath}; no path segment matches assembly ${assemblyName}`, + ); +} + +function applyMove(directive) { + const projectRoot = + directive.projectRoot ?? inferProjectRoot(directive.oldPath, directive.assemblyName); + const newDir = path.dirname(directive.newPath); + mkdirSync(newDir, { recursive: true }); + execFileSync('git', ['mv', directive.oldPath, directive.newPath], { stdio: 'inherit' }); + + if (directive.newPath.endsWith('.cs')) { + const newNamespace = deriveNamespace({ + assemblyName: directive.assemblyName, + projectRoot, + filePath: directive.newPath, + }); + const source = readFileSync(directive.newPath, 'utf8'); + const rewritten = rewriteNamespace(source, newNamespace); + if (rewritten !== source) { + writeFileSync(directive.newPath, rewritten); + execFileSync('git', ['add', directive.newPath], { stdio: 'inherit' }); + } + } +} + +function main(argv) { + const manifestPath = argv[2]; + if (!manifestPath) { + console.error('Usage: node scripts/feature-folder-migrate.mjs '); + process.exit(2); + } + const text = readFileSync(manifestPath, 'utf8'); + const directives = parseManifest(text); + for (const d of directives) { + applyMove(d); + } + console.log(`Applied ${directives.length} move(s) from ${manifestPath}`); +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) { + main(process.argv); +} diff --git a/scripts/feature-folder-migrate.test.mjs b/scripts/feature-folder-migrate.test.mjs new file mode 100644 index 00000000..3a73ffb7 --- /dev/null +++ b/scripts/feature-folder-migrate.test.mjs @@ -0,0 +1,107 @@ +// scripts/feature-folder-migrate.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deriveNamespace, rewriteNamespace, parseManifest } from './feature-folder-migrate.mjs'; + +test('deriveNamespace: file at project root → assembly name only', () => { + const ns = deriveNamespace({ + assemblyName: 'SimpleModule.Notifications', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications', + filePath: 'modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs', + }); + assert.equal(ns, 'SimpleModule.Notifications'); +}); + +test('deriveNamespace: nested feature folder → dotted segments', () => { + const ns = deriveNamespace({ + assemblyName: 'SimpleModule.Notifications', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications', + filePath: + 'modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs', + }); + assert.equal(ns, 'SimpleModule.Notifications.Features.Notifications.List'); +}); + +test('deriveNamespace: Contracts assembly with Features subtree', () => { + const ns = deriveNamespace({ + assemblyName: 'SimpleModule.Notifications.Contracts', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications.Contracts', + filePath: + 'modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs', + }); + assert.equal(ns, 'SimpleModule.Notifications.Contracts.Features.Notifications.List'); +}); + +test('deriveNamespace: throws when filePath is outside projectRoot', () => { + assert.throws( + () => + deriveNamespace({ + assemblyName: 'SimpleModule.Notifications', + projectRoot: 'modules/Notifications/src/SimpleModule.Notifications', + filePath: 'modules/Other/SomeFile.cs', + }), + /not under project root/, + ); +}); + +test('rewriteNamespace: replaces existing file-scoped namespace', () => { + const before = [ + 'using System;', + '', + 'namespace SimpleModule.Notifications.Endpoints.Notifications;', + '', + 'public class Foo {}', + ].join('\n'); + const after = rewriteNamespace(before, 'SimpleModule.Notifications.Features.Notifications.List'); + assert.match(after, /^namespace SimpleModule\.Notifications\.Features\.Notifications\.List;$/m); + assert.doesNotMatch(after, /Endpoints\.Notifications/); +}); + +test('rewriteNamespace: preserves trailing whitespace and other lines', () => { + const before = 'namespace A.B;\n\npublic class X {}\n'; + const after = rewriteNamespace(before, 'A.C'); + assert.equal(after, 'namespace A.C;\n\npublic class X {}\n'); +}); + +test('rewriteNamespace: no-op when target equals current', () => { + const before = 'namespace A.B;\n\npublic class X {}\n'; + const after = rewriteNamespace(before, 'A.B'); + assert.equal(after, before); +}); + +test('rewriteNamespace: throws on block-scoped namespace (unsupported)', () => { + const before = 'namespace A.B\n{\n public class X {}\n}\n'; + assert.throws( + () => rewriteNamespace(before, 'A.C'), + /file-scoped namespace declaration/, + ); +}); + +test('rewriteNamespace: throws when no namespace declaration found', () => { + const before = 'public class X {}\n'; + assert.throws(() => rewriteNamespace(before, 'A.B'), /no file-scoped namespace/); +}); + +test('parseManifest: skips comments and blank lines, parses TSV rows', () => { + const input = [ + '# header comment', + '', + 'a/b.cs\ta/c/b.cs\tSimpleModule.X', + ' ', + 'foo.cs\tbar/foo.cs\tSimpleModule.Y\tmodules/Y/src/SimpleModule.Y', + ].join('\n'); + const rows = parseManifest(input); + assert.deepEqual(rows, [ + { oldPath: 'a/b.cs', newPath: 'a/c/b.cs', assemblyName: 'SimpleModule.X', projectRoot: undefined }, + { + oldPath: 'foo.cs', + newPath: 'bar/foo.cs', + assemblyName: 'SimpleModule.Y', + projectRoot: 'modules/Y/src/SimpleModule.Y', + }, + ]); +}); + +test('parseManifest: throws on malformed row (wrong column count)', () => { + assert.throws(() => parseManifest('only-one-column.cs\n'), /expected 3 or 4 tab-separated columns/); +}); diff --git a/scripts/manifests/notifications.tsv b/scripts/manifests/notifications.tsv new file mode 100644 index 00000000..a5b7f579 --- /dev/null +++ b/scripts/manifests/notifications.tsv @@ -0,0 +1,24 @@ +# Notifications pilot — feature-folder migration manifest. +# Columns: OLD_PATH NEW_PATH ASSEMBLY_NAME + +# --- Infrastructure moves (impl project) --- +modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsDbContext.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationService.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Notifier.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/NotificationsLog.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/INotificationChannelRegistry.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/DatabaseChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/MailChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Channels/LogSmsChannel.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/EntityConfigurations/NotificationConfiguration.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs modules/Notifications/src/SimpleModule.Notifications/Infrastructure/Jobs/DispatchNotificationJob.cs SimpleModule.Notifications + +# --- Feature moves: endpoints (impl project) --- +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/List/ListNotificationsEndpoint.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/UnreadCount/UnreadCountEndpoint.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkRead/MarkReadEndpoint.cs SimpleModule.Notifications +modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs modules/Notifications/src/SimpleModule.Notifications/Features/Notifications/MarkAllRead/MarkAllReadEndpoint.cs SimpleModule.Notifications + +# --- Feature-scoped DTO move (contracts project) --- +modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs modules/Notifications/src/SimpleModule.Notifications.Contracts/Features/Notifications/List/QueryNotificationsRequest.cs SimpleModule.Notifications.Contracts diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 00000000..4bf29660 --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,13 @@ +## Vertical-slice feature-folder pilot (Notifications, 2026-05-14) + +- **Partial-class fragments break folder=namespace.** All fragments of a `partial class` must share a namespace. Fragments under `Features///` therefore declare the owning class's namespace (`Infrastructure/`), not the folder-derived one. Spec C1 amended. +- **EF migration snapshots reference contract types by full namespace.** Moving entity/value-object types into `Contracts/Shared/` would force migration regeneration — out of scope for the pilot. Future phases must decide: keep entities at `Contracts/` root forever, or accept migration churn. The Notifications pilot kept all entities and channel-payload DTOs at root. +- **`dotnet tool restore` is a one-time worktree setup** — csharpier (used by pre-commit lint-staged) isn't on PATH until you restore. Add this to per-module migration plans' pre-flight. +- **Cross-cutting consumers extend beyond the module folder.** The Notifications migration also required updates to `tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory*.cs` because the test factory imported `SimpleModule.Notifications.Services`. Future module migrations: grep `tests/` for the old namespaces, not just `modules//`. +- **The contract interface itself is a consumer.** `INotificationsContracts.cs` (in the Contracts assembly) declares methods whose parameter types include `QueryNotificationsRequest`. When that request type moved into `Contracts/Features/`, the interface file also needed the new `using`. Future Contracts splits: check the contract interface alongside endpoint and service files. +- **Shared test fixtures must use properties, not fields.** Repo analyzers `CA1051` (no visible instance fields) and `CA1063` (full `IDisposable` pattern) fire on `protected readonly Foo Field;` in abstract fixture classes. Convert to `private` backing field + `protected` property and implement the canonical `Dispose(bool)` pattern. +- **Plan-spec regex defect (fixed in 05058239).** The plan's `FILE_SCOPED_NS = /^namespace\s+([A-Za-z_][\w.]*)\s*;\s*$/m` greedily consumes the blank line after the namespace declaration in `m` mode, breaking the very test the plan asserts. The committed regex omits `\s*$` and matches only `namespace X;` — replacement preserves everything after the semicolon. +- **`packages/SimpleModule.Client/src/routes.ts` regenerates on every build.** The source generator emits routes there. Migrations should leave it unstaged; do not include it in feature-folder refactor commits. (It will pick up a separate format-fix commit later if needed.) +- **Cross-module `using SimpleModule.Notifications.Contracts;` count: zero.** Per-module Contracts reshape is purely internal for Notifications. Repeat this grep before starting each module's migration to size the blast radius. +- **Migration script (TSV manifest + `git mv` + namespace rewrite) worked cleanly.** ~16 moves applied in two batches (Infrastructure first, then Features+Contracts) each ending in a passing build. No source-generator surprises. Tool is ready to use on other modules. +- **Do NOT use the migration script to move pre-existing partial-class fragment files.** The script unconditionally rewrites the moved file's namespace to match its new folder. For a partial-class fragment, this would put the fragment in a different namespace than its root partial, silently creating a second distinct type and tripping `SM0025` ("no implementation registered for `IXyzContracts`"). Modules that already use partials (e.g. AuditLogs) must move those fragment files with a plain `git mv` and leave their namespace declarations untouched — keep the TSV manifest for non-partial files only. diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs index facb1899..73a632b3 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs @@ -10,6 +10,7 @@ using SimpleModule.FileStorage; using SimpleModule.Host; using SimpleModule.Notifications; +using SimpleModule.Notifications.Infrastructure; using SimpleModule.OpenIddict; using SimpleModule.Permissions; using SimpleModule.RateLimiting; diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index a4f6af3c..4ada7bc9 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -12,6 +12,7 @@ using SimpleModule.FileStorage; using SimpleModule.Host; using SimpleModule.Notifications; +using SimpleModule.Notifications.Infrastructure; using SimpleModule.OpenIddict; using SimpleModule.OpenIddict.Contracts; using SimpleModule.Permissions;