feat(301): third-party user management#458
Conversation
Adds ticket, plan, and tasks documentation for issue #301 covering the full CRUD workflow for third-party users including LaunchDarkly A/B testing of the subscriptions UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add libs/third-party-user module with Prisma schema, service, and validation - Add third-party user pages (list, create, manage, subscriptions, delete) - Add LaunchDarkly feature flag integration for subscription UI variant - Update LD flag key to third-party-subscriptions-radio-buttons - Add E2E tests for third-party user management journeys - Fix web dev script to preload DATABASE_URL before ESM module evaluation - Add database migration for third_party_user and third_party_subscription tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Static imports are hoisted in ESM, so env vars were unavailable when modules like LaunchDarkly initialised their SDK key at load time. Switching to dynamic import ensures dotenv.config() runs first. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughAdds a new third‑party user feature: a new library with Prisma schema and services, DB migration, admin UI (create/summary/confirmation, manage, subscriptions with A/B via LaunchDarkly, delete), session-backed flows, tests, docs, and env loading changes in server startup; tooling hook and tsconfig/path aliases updated. Changes
Sequence Diagram(s)sequenceDiagram
participant User as System Admin
participant Browser as Browser
participant Server as Express Server
participant LD as LaunchDarkly
participant DB as Prisma/Database
participant Session as Session Store
User->>Browser: Open create page
Browser->>Server: GET /third-party-users/create
Server->>Session: Load session.thirdPartyCreate
Server->>Browser: Render create form
User->>Browser: Submit name
Browser->>Server: POST /third-party-users/create {name}
Server->>Server: validateName
Server->>Session: Save session.thirdPartyCreate{name}
Server->>Browser: Redirect to summary
Browser->>Server: POST /third-party-users/create/summary (confirm)
Server->>DB: createThirdPartyUser(name)
DB-->>Server: user {id,name}
Server->>Session: Set createdId/createdName
Server->>Browser: Redirect to confirmation
sequenceDiagram
participant User as System Admin
participant Browser as Browser
participant Server as Express Server
participant LD as LaunchDarkly
participant DB as Prisma/Database
participant Session as Session Store
User->>Browser: Open subscriptions page
Browser->>Server: GET /third-party-users/{id}/subscriptions?page=1
Server->>DB: findThirdPartyUserById(id)
Server->>LD: isFeatureEnabled(flag, userId)
LD-->>Server: flagResult
Server->>Session: Init/restore pending subscriptions
Server->>DB: fetch list types (via list-types-common)
Server->>Browser: Render subscriptions page (UI variant from flag)
User->>Browser: Submit page selections
Browser->>Server: POST /.../subscriptions?page=N
Server->>Session: Update pending
alt not last page
Server->>Browser: Redirect to next page
else last page
Server->>DB: $transaction(deleteMany, createMany)
DB-->>Server: transaction result
Server->>Session: Clear pending
Server->>Browser: Redirect to confirmation
end
Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🎭 Playwright E2E Test Results257 tests 257 ✅ 24m 38s ⏱️ Results for commit f77c5aa. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
Actionable comments posted: 15
🧹 Nitpick comments (13)
libs/system-admin-pages/src/feature-flags/launch-darkly.ts (1)
14-18: Please avoid silent LaunchDarkly initialisation failures.Line 16 swallows the error completely. Add a warning/error log (ideally once) so LD outages or bad SDK config are observable in production.
libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.njk (1)
8-11: Prefertextoverhtmlfor panel body content.If
panelBodyis plain copy, render it withtextto reduce XSS exposure from future content changes.Suggested change
- {{ govukPanel({ - titleText: panelTitle, - html: panelBody - }) }} + {{ govukPanel({ + titleText: panelTitle, + text: panelBody + }) }}libs/system-admin-pages/src/pages/third-party-users/index.njk (1)
4-4: Unused import:govukBackLinkmacro.The macro is imported but the backLink block uses a plain anchor tag instead. Remove the unused import for consistency.
🧹 Proposed fix
{% from "govuk/components/table/macro.njk" import govukTable %} -{% from "govuk/components/back-link/macro.njk" import govukBackLink %}libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.njk (1)
3-3: Unused import:govukButtonmacro.The button macro is imported but not used on this confirmation page. Remove to keep imports clean.
🧹 Proposed fix
{% from "govuk/components/panel/macro.njk" import govukPanel %} -{% from "govuk/components/button/macro.njk" import govukButton %}libs/system-admin-pages/src/pages/third-party-users/create/index.njk (1)
5-5: Unused import:govukBackLinkmacro.Same as other templates - the macro is imported but the backLink block uses a plain anchor. Remove for consistency.
🧹 Proposed fix
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} -{% from "govuk/components/back-link/macro.njk" import govukBackLink %}libs/third-party-user/src/name-validation.ts (1)
2-2: ASCII regex limits names to basic Latin characters, inconsistent with potential international users.The regex currently restricts names to ASCII letters (a–z, A–Z). Whilst this works for English and Welsh, if third-party users include those with accented characters (é, ñ, ü) or non-Latin scripts, names will be rejected. The codebase supports only English and Welsh locales currently, so this is not an immediate concern, but consider using Unicode-aware patterns (
/^[\p{L}\p{N} '-]+$/u) if international character support becomes required.libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.njk (2)
1-5: Missing error handling withgovukErrorSummary.The template imports
govukRadiosbut doesn't use it (raw HTML radio inputs are used instead). More importantly, there's nogovukErrorSummaryimport or usage for displaying validation errors. As per coding guidelines, Nunjucks templates should include error handling withgovukErrorSummary.Consider adding error handling:
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}And conditionally render it when errors exist.
6-8: Consider using thegovukBackLinkmacro.The back link is implemented with a raw anchor element, but
govukBackLinkis imported and unused. Using the macro ensures consistent styling and behaviour.♻️ Suggested change
{% block backLink %} - <a href="/third-party-users/{{ userId }}{{ lngParam }}" class="govuk-back-link">{{ back }}</a> + {{ govukBackLink({ + href: "/third-party-users/" + userId + lngParam, + text: back + }) }} {% endblock %}libs/system-admin-pages/src/pages/third-party-users/[id]/index.ts (1)
29-29: Date locale is hardcoded regardless of language selection.The date is formatted with
"en-GB"locale even when Welsh (cy) is selected. Consider using the selected language for date formatting.♻️ Suggested fix
- createdAt: user.createdAt.toLocaleDateString("en-GB"), + createdAt: user.createdAt.toLocaleDateString(language === "cy" ? "cy-GB" : "en-GB"),libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.test.ts (1)
24-51: Consider adding Welsh language test forgetHandler.The
postHandlertests include Welsh parameter handling (line 116-128), butgetHandlerlacks a test verifying Welsh content rendering whenreq.query.lng === "cy".libs/third-party-user/src/third-party-user-service.ts (1)
44-46: Consider handling non-existent user deletion gracefully.
prisma.deletethrowsPrismaClientKnownRequestErrorwith codeP2025if the record doesn't exist. Depending on requirements, you may want to handle this case explicitly.docs/tickets/301/tasks.md (1)
19-19: Incomplete task:CATH_LD_KEYenvironment variable configuration.This task remains unchecked. Ensure the environment variable is documented in
.env.examplebefore merging, or track it as a follow-up issue.Would you like me to open an issue to track adding
CATH_LD_KEYto.env.example?libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.test.ts (1)
142-185: Consider adding a test for pagination redirect.The
postHandlertests cover the final-page save scenario but don't verify the redirect to the next page whenisLastPageis false. This is an important code path in the source handler (lines 90-93).📝 Suggested test case
+ it("should redirect to next page when not on last page", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + req.session = { thirdPartySubscriptions: { userId: "user-1", pending: {} } } as never; + req.query = { page: "1" }; + req.body = { CIVIL_DAILY_CAUSE_LIST: "PUBLIC" }; + // Mock more list types to have multiple pages + vi.mocked(await import("@hmcts/list-types-common")).mockListTypes = Array(25).fill(mockUser.subscriptions[0]); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/user-1/subscriptions?page=2"); + });
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c38d5512-3025-4a16-8ff4-05d82f7ec3f2
⛔ Files ignored due to path filters (1)
yarn.lockis excluded by!**/yarn.lock,!**/*.lock
📒 Files selected for processing (67)
.claude/hooks/post-write.shapps/api/src/server.tsapps/postgres/prisma/migrations/20260318000000_add_third_party_user/migration.sqlapps/postgres/src/schema-discovery.tsapps/web/package.jsonapps/web/src/server.tsdocs/tickets/301/plan.mddocs/tickets/301/tasks.mddocs/tickets/301/ticket.mde2e-tests/tests/third-party-user-management.spec.tslibs/system-admin-pages/package.jsonlibs/system-admin-pages/src/feature-flags/launch-darkly.tslibs/system-admin-pages/src/pages/third-party-users/[id]/cy.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/cy.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/en.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.njklibs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.test.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/cy.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/en.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.njklibs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.test.tslibs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.tslibs/system-admin-pages/src/pages/third-party-users/[id]/en.tslibs/system-admin-pages/src/pages/third-party-users/[id]/index.njklibs/system-admin-pages/src/pages/third-party-users/[id]/index.test.tslibs/system-admin-pages/src/pages/third-party-users/[id]/index.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/cy.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/en.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.njklibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.test.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/cy.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/en.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.njklibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.test.tslibs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.tslibs/system-admin-pages/src/pages/third-party-users/create/confirmation/cy.tslibs/system-admin-pages/src/pages/third-party-users/create/confirmation/en.tslibs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.njklibs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.test.tslibs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.tslibs/system-admin-pages/src/pages/third-party-users/create/cy.tslibs/system-admin-pages/src/pages/third-party-users/create/en.tslibs/system-admin-pages/src/pages/third-party-users/create/index.njklibs/system-admin-pages/src/pages/third-party-users/create/index.test.tslibs/system-admin-pages/src/pages/third-party-users/create/index.tslibs/system-admin-pages/src/pages/third-party-users/create/summary/cy.tslibs/system-admin-pages/src/pages/third-party-users/create/summary/en.tslibs/system-admin-pages/src/pages/third-party-users/create/summary/index.njklibs/system-admin-pages/src/pages/third-party-users/create/summary/index.test.tslibs/system-admin-pages/src/pages/third-party-users/create/summary/index.tslibs/system-admin-pages/src/pages/third-party-users/cy.tslibs/system-admin-pages/src/pages/third-party-users/en.tslibs/system-admin-pages/src/pages/third-party-users/index.njklibs/system-admin-pages/src/pages/third-party-users/index.test.tslibs/system-admin-pages/src/pages/third-party-users/index.tslibs/third-party-user/package.jsonlibs/third-party-user/prisma/schema.prismalibs/third-party-user/src/config.tslibs/third-party-user/src/index.tslibs/third-party-user/src/name-validation.test.tslibs/third-party-user/src/name-validation.tslibs/third-party-user/src/third-party-user-service.test.tslibs/third-party-user/src/third-party-user-service.tslibs/third-party-user/tsconfig.jsontsconfig.json
| # Filter to only TypeScript/JavaScript files (skip .njk, .json, .prisma, .sh, etc.) | ||
| TS_FILES="" | ||
| while IFS= read -r f; do | ||
| case "$f" in | ||
| *.ts|*.tsx|*.js|*.jsx) | ||
| if [ -f "$f" ]; then | ||
| TS_FILES="$TS_FILES $f" | ||
| fi | ||
| ;; | ||
| esac | ||
| done <<< "$FILES_TO_CHECK" | ||
|
|
||
| if [ -z "$TS_FILES" ]; then | ||
| log_hook "No TS/JS files to check" | ||
| exit 0 | ||
| fi |
There was a problem hiding this comment.
Paths with spaces will break due to word-splitting.
The $TS_FILES variable is built by concatenation and later expanded unquoted (lines 63, 67). Filenames containing spaces will be incorrectly split.
🛠️ Suggested fix using an array
-# Filter to only TypeScript/JavaScript files (skip .njk, .json, .prisma, .sh, etc.)
-TS_FILES=""
-while IFS= read -r f; do
- case "$f" in
- *.ts|*.tsx|*.js|*.jsx)
- if [ -f "$f" ]; then
- TS_FILES="$TS_FILES $f"
- fi
- ;;
- esac
-done <<< "$FILES_TO_CHECK"
-
-if [ -z "$TS_FILES" ]; then
- log_hook "No TS/JS files to check"
- exit 0
-fi
+# Filter to only TypeScript/JavaScript files (skip .njk, .json, .prisma, .sh, etc.)
+TS_FILES=()
+while IFS= read -r f; do
+ case "$f" in
+ *.ts|*.tsx|*.js|*.jsx)
+ if [ -f "$f" ]; then
+ TS_FILES+=("$f")
+ fi
+ ;;
+ esac
+done <<< "$FILES_TO_CHECK"
+
+if [ ${`#TS_FILES`[@]} -eq 0 ]; then
+ log_hook "No TS/JS files to check"
+ exit 0
+fiThen update the biome invocations:
-if ! $BIOME_BIN format --write $TS_FILES 2>&1; then
+if ! "$BIOME_BIN" format --write "${TS_FILES[@]}" 2>&1; then
log_hook "Format had issues (non-blocking)"
fi
-if ! $BIOME_BIN check --write $TS_FILES 2>&1; then
+if ! "$BIOME_BIN" check --write "${TS_FILES[@]}" 2>&1; then
log_hook "Lint had issues (non-blocking)"
fi|
|
||
| Each page follows the existing pattern: `index.ts` (controller), `en.ts`, `cy.ts`, `index.njk`, `index.test.ts`. | ||
|
|
||
| ``` |
There was a problem hiding this comment.
Add language specifier to fenced code block.
Static analysis flagged this code block as missing a language specifier.
📝 Proposed fix
-```
+```text
libs/system-admin-pages/src/pages/🧰 Tools
🪛 markdownlint-cli2 (0.21.0)
[warning] 75-75: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
| - LaunchDarkly flag key: `third-party-subscriptions-radio-buttons` (false = dropdown, true = radio buttons) | ||
| - `CATH_LD_KEY` environment variable must be set for LaunchDarkly to function; the feature flag defaults to `false` (radio button variant) when unavailable. |
There was a problem hiding this comment.
Same inconsistency as in ticket.md regarding feature flag default.
Line 84 says false = dropdown, but line 85 says it defaults to "radio button variant". Please align with the correction in ticket.md.
| * Manage subscriptions - Rheoli tanysgrifiadau | ||
| * Delete user - Dileu Defnyddiwr |
There was a problem hiding this comment.
Inconsistent documentation about feature flag default behaviour.
Line 84 states false = dropdown, true = radio buttons, but line 85 says the flag "defaults to false (radio button variant)". These contradict each other.
📝 Suggested fix
-- `CATH_LD_KEY` environment variable must be set for LaunchDarkly to function; the feature flag defaults to `false` (radio button variant) when unavailable.
+- `CATH_LD_KEY` environment variable must be set for LaunchDarkly to function; the feature flag defaults to `false` (dropdown variant) when unavailable.| test("user can delete a third party user @nightly", async ({ page }) => { | ||
| // Create a test user directly in DB | ||
| const testUser = await prisma.thirdPartyUser.create({ data: { name: `Delete Test Corp ${Date.now()}` } }); | ||
|
|
There was a problem hiding this comment.
Add user to cleanup list for resilience.
If this test fails before the deletion step, the created user will remain in the database. Track it in createdUserIds for cleanup.
🛡️ Proposed fix
test("user can delete a third party user `@nightly`", async ({ page }) => {
// Create a test user directly in DB
const testUser = await prisma.thirdPartyUser.create({ data: { name: `Delete Test Corp ${Date.now()}` } });
+ createdUserIds.push(testUser.id);
await page.goto(`/third-party-users/${testUser.id}`);
libs/system-admin-pages/src/pages/third-party-users/create/summary/index.njk
Show resolved
Hide resolved
libs/system-admin-pages/src/pages/third-party-users/create/summary/index.ts
Show resolved
Hide resolved
| model ThirdPartyUser { | ||
| id String @id @default(cuid()) | ||
| name String @db.VarChar(255) | ||
| createdAt DateTime @default(now()) @map("created_at") | ||
| subscriptions ThirdPartySubscription[] | ||
|
|
||
| @@map("third_party_user") | ||
| } |
There was a problem hiding this comment.
Consider adding a unique constraint on name.
Without a unique constraint, the idempotent create logic in third-party-user-service.ts is vulnerable to race conditions, potentially creating duplicate users with the same name.
♻️ Suggested change
model ThirdPartyUser {
id String `@id` `@default`(cuid())
- name String `@db.VarChar`(255)
+ name String `@unique` `@db.VarChar`(255)
createdAt DateTime `@default`(now()) `@map`("created_at")
subscriptions ThirdPartySubscription[]
@@map("third_party_user")
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| model ThirdPartyUser { | |
| id String @id @default(cuid()) | |
| name String @db.VarChar(255) | |
| createdAt DateTime @default(now()) @map("created_at") | |
| subscriptions ThirdPartySubscription[] | |
| @@map("third_party_user") | |
| } | |
| model ThirdPartyUser { | |
| id String `@id` `@default`(cuid()) | |
| name String `@unique` `@db.VarChar`(255) | |
| createdAt DateTime `@default`(now()) `@map`("created_at") | |
| subscriptions ThirdPartySubscription[] | |
| @@map("third_party_user") | |
| } |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Node.js 24 throws if the --env-file path doesn't exist. In CI there is no .env file so the web server process exits immediately with no output, causing Playwright's webServer timeout. server.ts already loads .env via dotenv.config() so the flag is redundant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|



Summary
third-party-subscriptions-radio-buttonsflag)Test plan
third-party-subscriptions-radio-buttonsflag in LaunchDarkly — subscriptions page should show radio buttons instead of dropdownsyarn test— all unit tests pass🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests
Documentation