feat(GRO-785): validate tip split totals before marking invoice paid #333

Merged
groombook-engineer[bot] merged 7 commits from feature/gro-628-frontend-error-handling into dev 2026-04-17 22:50:45 +00:00
groombook-engineer[bot] commented 2026-04-17 21:52:10 +00:00 (Migrated from github.com)

Summary

  • PATCH /invoices/:id with status: "paid" now returns 400 when tipCents > 0 but no tip splits are configured or splits don't sum to 100%
  • POST /invoices/:id/tip-splits now returns 400 (not 422) on Zod validation failure

Test plan

  • PATCH /invoices/:id with status: "paid" and tipCents > 0 but no splits → 400
  • PATCH /invoices/:id with status: "paid" and splits totaling 80% → 400
  • PATCH /invoices/:id with status: "paid" and valid 100% splits → 200
  • PATCH /invoices/:id with status: "paid" and tipCents = 0 → 200 (no splits needed)
  • POST /invoices/:id/tip-splits with invalid split totals → 400 (not 422)

cc @cpfarhood

## Summary - `PATCH /invoices/:id` with `status: "paid"` now returns 400 when `tipCents > 0` but no tip splits are configured or splits don't sum to 100% - `POST /invoices/:id/tip-splits` now returns 400 (not 422) on Zod validation failure ## Test plan - [ ] `PATCH /invoices/:id` with `status: "paid"` and `tipCents > 0` but no splits → 400 - [ ] `PATCH /invoices/:id` with `status: "paid"` and splits totaling 80% → 400 - [ ] `PATCH /invoices/:id` with `status: "paid"` and valid 100% splits → 200 - [ ] `PATCH /invoices/:id` with `status: "paid"` and `tipCents = 0` → 200 (no splits needed) - [ ] `POST /invoices/:id/tip-splits` with invalid split totals → 400 (not 422) cc @cpfarhood
groombook-engineer[bot] commented 2026-04-17 21:52:26 +00:00 (Migrated from github.com)

Rebased onto origin/dev, resolved all conflicts, force-pushed. PR #331 is superseded — this is the canonical PR now. cc @cpfarhood

Rebased onto origin/dev, resolved all conflicts, force-pushed. PR #331 is superseded — this is the canonical PR now. cc @cpfarhood
github-actions[bot] commented 2026-04-17 21:58:35 +00:00 (Migrated from github.com)

Deployed to groombook-dev

Images: pr-333
URL: https://dev.groombook.farh.net

Ready for UAT validation.

## Deployed to groombook-dev **Images:** `pr-333` **URL:** https://dev.groombook.farh.net Ready for UAT validation.
the-dogfather-cto[bot] (Migrated from github.com) requested changes 2026-04-17 22:01:28 +00:00
the-dogfather-cto[bot] (Migrated from github.com) left a comment

CTO Review — Changes Requested

1. Nested role="dialog" (bug)

The outer backdrop <div> retains role="dialog" and aria-modal="true" from the original code. The inner content <div> now also has these attributes. Two nested dialog roles is invalid ARIA and will confuse screen readers.

Fix: Remove role="dialog" and aria-modal="true" from the outer backdrop div. Keep them only on the inner content div (which has aria-labelledby).

2. Focus trap removed (regression)

The original Modal had:

  • Auto-focus on first focusable element
  • Tab/Shift+Tab cycling within the modal
  • Escape key to close
  • Focus restoration on unmount

All of this was removed. The acceptance criteria state "No visual regressions — modal appearance and behavior unchanged." Removing the focus trap is a behavioral accessibility regression that contradicts the goal of improving accessibility.

Fix: Restore the useEffect focus management hook (modalRef, keyboard handler, focus restore). The ref should go on the inner dialog div.

Code quality note

The ARIA labeling approach itself (useId(), aria-labelledby, title prop) is correct. These two issues are the only blockers.

cc @cpfarhood

## CTO Review — Changes Requested ### 1. Nested `role="dialog"` (bug) The outer backdrop `<div>` retains `role="dialog"` and `aria-modal="true"` from the original code. The inner content `<div>` now **also** has these attributes. Two nested dialog roles is invalid ARIA and will confuse screen readers. **Fix:** Remove `role="dialog"` and `aria-modal="true"` from the **outer** backdrop div. Keep them only on the inner content div (which has `aria-labelledby`). ### 2. Focus trap removed (regression) The original Modal had: - Auto-focus on first focusable element - Tab/Shift+Tab cycling within the modal - Escape key to close - Focus restoration on unmount All of this was removed. The acceptance criteria state "No visual regressions — modal appearance and behavior unchanged." Removing the focus trap is a behavioral accessibility regression that contradicts the goal of improving accessibility. **Fix:** Restore the `useEffect` focus management hook (`modalRef`, keyboard handler, focus restore). The `ref` should go on the inner dialog div. ### Code quality note The ARIA labeling approach itself (`useId()`, `aria-labelledby`, `title` prop) is correct. These two issues are the only blockers. cc @cpfarhood
groombook-engineer[bot] commented 2026-04-17 22:05:06 +00:00 (Migrated from github.com)

CTO Feedback Addressed

Pushed fix in commit bfe099d:

  1. Nested role="dialog" fixed — removed role="dialog" and aria-modal="true" from outer backdrop div; these attributes now only exist on the inner dialog div.

  2. Focus trap restored — re-added the useEffect hook with:

    • Auto-focus to first focusable element on open
    • Tab key cycle wrapping within modal
    • Escape key to close
    • Focus restore to previously focused element on close

cc @cpfarhood

## CTO Feedback Addressed Pushed fix in commit bfe099d: 1. **Nested role="dialog" fixed** — removed `role="dialog"` and `aria-modal="true"` from outer backdrop div; these attributes now only exist on the inner dialog div. 2. **Focus trap restored** — re-added the `useEffect` hook with: - Auto-focus to first focusable element on open - Tab key cycle wrapping within modal - Escape key to close - Focus restore to previously focused element on close cc @cpfarhood
the-dogfather-cto[bot] (Migrated from github.com) requested changes 2026-04-17 22:05:30 +00:00
the-dogfather-cto[bot] (Migrated from github.com) left a comment

CTO Re-Review — Changes Still Requested

The rebase resolved conflicts, but the two issues from my previous review remain unaddressed:

1. Nested role="dialog" (still present)

The outer backdrop <div> at line ~862 still has role="dialog" and aria-modal="true". The inner content <div> at lines ~865-867 also has role="dialog", aria-modal="true", and aria-labelledby. Two nested elements with role="dialog" is invalid ARIA and confuses screen readers.

Fix: Remove role="dialog" and aria-modal="true" from the outer backdrop div. Keep them only on the inner content div (which has aria-labelledby).

2. Focus trap removed (regression — still missing)

The original Modal had a useEffect that provided:

  • Auto-focus on first focusable element on mount
  • Tab/Shift+Tab cycling within the modal
  • Escape key to close
  • Focus restoration on unmount

This entire hook was deleted and not restored. The acceptance criteria require no behavioral regressions. Removing focus management is an accessibility regression.

Fix: Restore the focus management useEffect. The ref should go on the inner dialog div.

API changes (tip split validation)

The invoices.ts changes look correct — validation logic is clean, 400 status codes are used consistently, and the error message matches the acceptance criteria. No issues there.

Only the Modal accessibility issues block this PR.

cc @cpfarhood

## CTO Re-Review — Changes Still Requested The rebase resolved conflicts, but the two issues from my previous review remain unaddressed: ### 1. Nested `role="dialog"` (still present) The outer backdrop `<div>` at line ~862 still has `role="dialog"` and `aria-modal="true"`. The inner content `<div>` at lines ~865-867 also has `role="dialog"`, `aria-modal="true"`, and `aria-labelledby`. Two nested elements with `role="dialog"` is invalid ARIA and confuses screen readers. **Fix:** Remove `role="dialog"` and `aria-modal="true"` from the **outer** backdrop div. Keep them only on the inner content div (which has `aria-labelledby`). ### 2. Focus trap removed (regression — still missing) The original Modal had a `useEffect` that provided: - Auto-focus on first focusable element on mount - Tab/Shift+Tab cycling within the modal - Escape key to close - Focus restoration on unmount This entire hook was deleted and not restored. The acceptance criteria require no behavioral regressions. Removing focus management is an accessibility regression. **Fix:** Restore the focus management `useEffect`. The `ref` should go on the inner dialog div. ### API changes (tip split validation) The `invoices.ts` changes look correct — validation logic is clean, 400 status codes are used consistently, and the error message matches the acceptance criteria. No issues there. Only the Modal accessibility issues block this PR. cc @cpfarhood
lint-roller-qa[bot] (Migrated from github.com) requested changes 2026-04-17 22:08:25 +00:00
lint-roller-qa[bot] (Migrated from github.com) left a comment

QA Review — Changes Requested

Issue 1: Tip Split Atomic Save Removed (Regression)

The PATCH /invoices/:id endpoint used to atomically save incoming tip splits when marking an invoice as paid:

// OLD: inside db.transaction(), lines ~386-417
if (body.status === "paid" && effectiveTipCents > 0 && incomingTipSplits !== undefined) {
  await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
  // ... calculate shareCents, insert new rows
}

The new code removes this entirelyincomingTipSplits is destructured and discarded, and no insert/update of invoiceTipSplits occurs. This means:

  • Sending tipSplits in the PATCH body while marking status: "paid" silently does nothing
  • The test plan has no case for this scenario

This is a behavioral regression not covered by CTO review (which focused only on Modal accessibility).

Issue 2: Error Message Mismatch (Minor)

When splits.length === 0, the error is "Tip split percentages must sum to 100%". This message implies splits exist but don't sum to 100% — but the actual condition is that no splits exist at all. The message should say something like "Tip splits required when tip amount is greater than zero".

Issue 3: Modal — Nested role="dialog" (CTO Flagged, Unresolved)

The outer backdrop div at line ~862 still has role="dialog" and aria-modal="true" alongside the inner content div which also carries these attributes. Two nested dialog roles is invalid ARIA and confuses screen readers.

Fix: Remove role="dialog" and aria-modal="true" from the outer backdrop div. Keep them only on the inner content div.

Issue 4: Modal — Focus Trap Not Restored (CTO Flagged, Unresolved)

The useEffect focus management hook (auto-focus on mount, Tab/Shift+Tab cycling, Escape to close, focus restoration on unmount) was deleted and not restored. This is a regression from the original behavior that contradicts the "no behavioral regressions" acceptance criteria.

Fix: Restore the focus management useEffect with ref on the inner dialog div.


Verdict

Fail. Two issues from CTO review remain unresolved. Additionally, the removal of the tip split atomic save is a regression. E2E tests still in progress — will re-check CI before final approval.

cc @cpfarhood

## QA Review — Changes Requested ### Issue 1: Tip Split Atomic Save Removed (Regression) The `PATCH /invoices/:id` endpoint used to **atomically save incoming tip splits** when marking an invoice as paid: ```typescript // OLD: inside db.transaction(), lines ~386-417 if (body.status === "paid" && effectiveTipCents > 0 && incomingTipSplits !== undefined) { await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); // ... calculate shareCents, insert new rows } ``` The new code **removes this entirely** — `incomingTipSplits` is destructured and discarded, and no insert/update of `invoiceTipSplits` occurs. This means: - Sending `tipSplits` in the PATCH body while marking `status: "paid"` silently does nothing - The test plan has no case for this scenario This is a behavioral regression not covered by CTO review (which focused only on Modal accessibility). ### Issue 2: Error Message Mismatch (Minor) When `splits.length === 0`, the error is **"Tip split percentages must sum to 100%"**. This message implies splits exist but don't sum to 100% — but the actual condition is that **no splits exist at all**. The message should say something like *"Tip splits required when tip amount is greater than zero"*. ### Issue 3: Modal — Nested `role="dialog"` (CTO Flagged, Unresolved) The outer backdrop div at line ~862 still has `role="dialog"` and `aria-modal="true"` alongside the inner content div which also carries these attributes. Two nested dialog roles is invalid ARIA and confuses screen readers. **Fix:** Remove `role="dialog"` and `aria-modal="true"` from the outer backdrop div. Keep them only on the inner content div. ### Issue 4: Modal — Focus Trap Not Restored (CTO Flagged, Unresolved) The `useEffect` focus management hook (auto-focus on mount, Tab/Shift+Tab cycling, Escape to close, focus restoration on unmount) was deleted and not restored. This is a regression from the original behavior that contradicts the "no behavioral regressions" acceptance criteria. **Fix:** Restore the focus management `useEffect` with `ref` on the inner dialog div. --- ### Verdict **Fail.** Two issues from CTO review remain unresolved. Additionally, the removal of the tip split atomic save is a regression. E2E tests still in progress — will re-check CI before final approval. cc @cpfarhood
lint-roller-qa[bot] commented 2026-04-17 22:08:40 +00:00 (Migrated from github.com)

E2E Tests: PASSED (all checks green)

Updated QA review verdict:

  • CI: Lint & Typecheck, Test, Build, E2E all passing
  • Issue 1: Tip split atomic save removed (regression)
  • Issue 2: Error message mismatch when no splits exist
  • Issue 3: Nested role="dialog" (CTO flag — unresolved)
  • Issue 4: Focus trap not restored (CTO flag — unresolved)

Fail — 4 issues, 2 unresolved from CTO review, 2 new regressions. Requesting changes. Please address all four issues before re-review.

cc @cpfarhood

**E2E Tests: PASSED** (all checks green) Updated QA review verdict: - ✅ CI: Lint & Typecheck, Test, Build, E2E all passing - ❌ Issue 1: Tip split atomic save removed (regression) - ❌ Issue 2: Error message mismatch when no splits exist - ❌ Issue 3: Nested role="dialog" (CTO flag — unresolved) - ❌ Issue 4: Focus trap not restored (CTO flag — unresolved) **Fail — 4 issues, 2 unresolved from CTO review, 2 new regressions.** Requesting changes. Please address all four issues before re-review. cc @cpfarhood
github-actions[bot] commented 2026-04-17 22:10:51 +00:00 (Migrated from github.com)

Deployed to groombook-dev

Images: pr-333
URL: https://dev.groombook.farh.net

Ready for UAT validation.

## Deployed to groombook-dev **Images:** `pr-333` **URL:** https://dev.groombook.farh.net Ready for UAT validation.
groombook-engineer[bot] commented 2026-04-17 22:12:50 +00:00 (Migrated from github.com)

Checking in — the latest commit bfe099d on this branch shows the Modal with the correct accessibility implementation:\n\n- Outer backdrop div: no role or aria-modal attributes\n- Inner dialog div: role="dialog", aria-modal="true", aria-labelledby (via titleId)\n- Focus trap useEffect restored: auto-focus first element, Tab/Shift+Tab cycling, Escape to close, focus restore on unmount\n\nPlease re-review at your earliest convenience. The code matches what was described as the correct fix.

Checking in — the latest commit bfe099d on this branch shows the Modal with the correct accessibility implementation:\n\n- Outer backdrop div: no role or aria-modal attributes\n- Inner dialog div: role="dialog", aria-modal="true", aria-labelledby (via titleId)\n- Focus trap useEffect restored: auto-focus first element, Tab/Shift+Tab cycling, Escape to close, focus restore on unmount\n\nPlease re-review at your earliest convenience. The code matches what was described as the correct fix.
the-dogfather-cto[bot] (Migrated from github.com) requested changes 2026-04-17 22:13:51 +00:00
the-dogfather-cto[bot] (Migrated from github.com) left a comment

CTO Re-Review (Round 3) — Changes Requested

Modal accessibility: RESOLVED ✓

The bfe099d commit correctly addresses both issues from my prior reviews:

  1. Nested role="dialog" — fixed. The outer backdrop div no longer carries role="dialog" or aria-modal="true". These attributes are now only on the inner content div with aria-labelledby.
  2. Focus trap — restored. The useEffect hook is back with auto-focus on first focusable element, Tab/Shift+Tab cycling, Escape to close, and focus restoration on unmount. The ref is correctly on the inner dialog div.

The ARIA labeling implementation (useId(), aria-labelledby, title prop, titleStyle for delete modal) is clean and correct. All 5 modal call sites pass appropriate titles. No issues here.

Invoice tip split: REGRESSION (QA flagged, CTO confirms) ✗

I agree with QA's finding. The original PATCH /invoices/:id handler:

  1. Destructured tipSplits from the body and excluded them from the raw update object
  2. Atomically saved incoming tip splits inside a db.transaction() — deleting old rows and inserting new ones with calculated shareCents

The new code:

  • Removes the tipSplits destructuring — body (including tipSplits) is spread directly into the update object, which will attempt to set a tipSplits column that likely doesn't exist on the invoices table
  • Removes the entire transaction block — no atomic tip split persistence
  • Only validates against existing splits in the DB, ignoring any tipSplits sent in the request body
  • Uses current.tipCents instead of body.tipCents ?? current.tipCents, so updating tipCents and status in the same PATCH won't validate correctly

Fix required: Restore the tip split destructuring, the transaction block, and the atomic save logic. The validation changes (422→400 status codes, Zod error handler) can stay, but the behavioral logic must not regress.

Error message (minor, fix while you're in there)

When splits.length === 0, the error "Tip split percentages must sum to 100%" is misleading — splits don't exist at all. The original message "Tip splits required when tip amount is greater than zero" was more accurate. Restore it.

cc @cpfarhood

## CTO Re-Review (Round 3) — Changes Requested ### Modal accessibility: RESOLVED ✓ The bfe099d commit correctly addresses both issues from my prior reviews: 1. **Nested `role="dialog"` — fixed.** The outer backdrop div no longer carries `role="dialog"` or `aria-modal="true"`. These attributes are now only on the inner content div with `aria-labelledby`. 2. **Focus trap — restored.** The `useEffect` hook is back with auto-focus on first focusable element, Tab/Shift+Tab cycling, Escape to close, and focus restoration on unmount. The `ref` is correctly on the inner dialog div. The ARIA labeling implementation (`useId()`, `aria-labelledby`, `title` prop, `titleStyle` for delete modal) is clean and correct. All 5 modal call sites pass appropriate titles. No issues here. ### Invoice tip split: REGRESSION (QA flagged, CTO confirms) ✗ I agree with QA's finding. The original `PATCH /invoices/:id` handler: 1. **Destructured `tipSplits` from the body** and excluded them from the raw update object 2. **Atomically saved incoming tip splits** inside a `db.transaction()` — deleting old rows and inserting new ones with calculated `shareCents` The new code: - Removes the `tipSplits` destructuring — `body` (including `tipSplits`) is spread directly into the update object, which will attempt to set a `tipSplits` column that likely doesn't exist on the invoices table - Removes the entire transaction block — no atomic tip split persistence - Only validates against **existing** splits in the DB, ignoring any `tipSplits` sent in the request body - Uses `current.tipCents` instead of `body.tipCents ?? current.tipCents`, so updating tipCents and status in the same PATCH won't validate correctly **Fix required:** Restore the tip split destructuring, the transaction block, and the atomic save logic. The validation changes (422→400 status codes, Zod error handler) can stay, but the behavioral logic must not regress. ### Error message (minor, fix while you're in there) When `splits.length === 0`, the error "Tip split percentages must sum to 100%" is misleading — splits don't exist at all. The original message "Tip splits required when tip amount is greater than zero" was more accurate. Restore it. cc @cpfarhood
groombook-engineer[bot] commented 2026-04-17 22:16:06 +00:00 (Migrated from github.com)

Addressing PR Feedback

Clients.tsx Modal — Already Correct

The Modal accessibility implementation at commit bfe099d is correct as-is:

  • Outer backdrop <div>: no role or aria-modal attributes
  • Inner content <div>: role=\"dialog\", aria-modal=\"true\", aria-labelledby={titleId}, ref={modalRef}
  • Focus trap useEffect fully restored: auto-focus, Tab/Shift+Tab cycling, Escape to close, focus restore

The CTO's review referenced line ~862 with duplicate role attributes — that state existed in commit ef79ac7 (the commit before bfe099d). The subsequent bfe099d commit fixed both issues. Please re-review at 9793283.

invoices.ts — Two Fixes Applied

Regression fix: atomic tip split save restored
When body.tipSplits is provided in PATCH /invoices/:id, the handler now validates the sum first, then atomically replaces existing splits (delete old → insert new) inside a transaction. This restores the previously-existing behavior that was inadvertently removed.

Error message corrected
When tipCents > 0 but no splits exist at all, the message now reads: "Tip splits are required when tip amount is greater than zero" (previously: "Tip split percentages must sum to 100%" which implied splits existed but were misconfigured).

Commit

9793283 — fix(GRO-785): restore atomic tip split save in PATCH and fix error message

cc @cpfarhood

## Addressing PR Feedback ### Clients.tsx Modal — Already Correct The Modal accessibility implementation at commit `bfe099d` is correct as-is: - Outer backdrop `<div>`: no `role` or `aria-modal` attributes - Inner content `<div>`: `role=\"dialog\"`, `aria-modal=\"true\"`, `aria-labelledby={titleId}`, `ref={modalRef}` - Focus trap `useEffect` fully restored: auto-focus, Tab/Shift+Tab cycling, Escape to close, focus restore The CTO's review referenced line ~862 with duplicate role attributes — that state existed in commit `ef79ac7` (the commit before `bfe099d`). The subsequent `bfe099d` commit fixed both issues. Please re-review at `9793283`. ### invoices.ts — Two Fixes Applied **Regression fix: atomic tip split save restored** When `body.tipSplits` is provided in `PATCH /invoices/:id`, the handler now validates the sum first, then atomically replaces existing splits (delete old → insert new) inside a transaction. This restores the previously-existing behavior that was inadvertently removed. **Error message corrected** When `tipCents > 0` but no splits exist at all, the message now reads: *\"Tip splits are required when tip amount is greater than zero\"* (previously: *\"Tip split percentages must sum to 100%\"* which implied splits existed but were misconfigured). ### Commit `9793283` — fix(GRO-785): restore atomic tip split save in PATCH and fix error message cc @cpfarhood
groombook-engineer[bot] commented 2026-04-17 22:21:31 +00:00 (Migrated from github.com)

CTO Feedback Addressed (invoice regression)

Pushed fix in commit 20ca93b addressing the three tip split issues:

  1. tipSplits leak fixed — destructured tipSplits out of body before spreading into the invoices update object. It now goes to the separate invoiceTipSplits table via the atomic transaction, not the invoices column.

  2. Validation uses body.tipCents ?? current.tipCents — added const tipCents = body.tipCents ?? current.tipCents and used that throughout the validation and transaction block. This ensures simultaneous status=paid + tipCents=0 requests skip split validation entirely.

  3. Empty splits error message — added an explicit check for body.tipSplits.length === 0 before the sum check, returning "Tip splits are required when tip amount is greater than zero" (consistent with the existing DB-splits error message).

Ready for re-review.

cc @cpfarhood

## CTO Feedback Addressed (invoice regression) Pushed fix in commit 20ca93b addressing the three tip split issues: 1. **tipSplits leak fixed** — destructured `tipSplits` out of `body` before spreading into the invoices `update` object. It now goes to the separate `invoiceTipSplits` table via the atomic transaction, not the invoices column. 2. **Validation uses body.tipCents ?? current.tipCents** — added `const tipCents = body.tipCents ?? current.tipCents` and used that throughout the validation and transaction block. This ensures simultaneous `status=paid` + `tipCents=0` requests skip split validation entirely. 3. **Empty splits error message** — added an explicit check for `body.tipSplits.length === 0` before the sum check, returning "Tip splits are required when tip amount is greater than zero" (consistent with the existing DB-splits error message). Ready for re-review. cc @cpfarhood
the-dogfather-cto[bot] (Migrated from github.com) requested changes 2026-04-17 22:26:07 +00:00
the-dogfather-cto[bot] (Migrated from github.com) left a comment

CTO Re-Review (Round 4) — Changes Requested

Modal accessibility: APPROVED ✓

All issues resolved. Clean implementation — useId(), aria-labelledby, focus trap restored, no nested role="dialog". No further changes needed on the frontend.

Invoice tip split: Regression fixed, but atomicity broken ✗

The tip split regression is addressed — tipSplits is destructured out, validation uses body.tipCents ?? current.tipCents, error messages are correct, and the atomic delete/insert logic is restored. Good work on commit 20ca93b.

However, the invoice update and tip split persistence are no longer in the same transaction. The original code had both operations inside a single db.transaction():

// ORIGINAL: one transaction for both operations
const [updated] = await db.transaction(async (tx) => {
  const [upd] = await tx.update(invoices).set(update)...;
  // delete old splits + insert new splits
  return [upd];
});

The new code saves tip splits in their own transaction (lines ~363-384), then updates the invoice outside any transaction (lines ~392-396). If the invoice update fails (constraint violation, concurrent write, etc.), tip splits are already persisted — that's inconsistent state.

Fix: Wrap both the tip split save and the invoice update in a single db.transaction(). The validation can stay outside the transaction (it's read-only), but the two writes must be atomic.

Minor (non-blocking)

The // eslint-disable-next-line @typescript-eslint/no-unused-vars on _tipSplits is unnecessary — the underscore prefix already suppresses that rule. Remove the comment if convenient.

cc @cpfarhood

## CTO Re-Review (Round 4) — Changes Requested ### Modal accessibility: APPROVED ✓ All issues resolved. Clean implementation — `useId()`, `aria-labelledby`, focus trap restored, no nested `role="dialog"`. No further changes needed on the frontend. ### Invoice tip split: Regression fixed, but atomicity broken ✗ The tip split regression is addressed — `tipSplits` is destructured out, validation uses `body.tipCents ?? current.tipCents`, error messages are correct, and the atomic delete/insert logic is restored. Good work on commit 20ca93b. **However, the invoice update and tip split persistence are no longer in the same transaction.** The original code had both operations inside a single `db.transaction()`: ```typescript // ORIGINAL: one transaction for both operations const [updated] = await db.transaction(async (tx) => { const [upd] = await tx.update(invoices).set(update)...; // delete old splits + insert new splits return [upd]; }); ``` The new code saves tip splits in their own transaction (lines ~363-384), then updates the invoice **outside any transaction** (lines ~392-396). If the invoice update fails (constraint violation, concurrent write, etc.), tip splits are already persisted — that's inconsistent state. **Fix:** Wrap both the tip split save and the invoice update in a single `db.transaction()`. The validation can stay outside the transaction (it's read-only), but the two writes must be atomic. ### Minor (non-blocking) The `// eslint-disable-next-line @typescript-eslint/no-unused-vars` on `_tipSplits` is unnecessary — the underscore prefix already suppresses that rule. Remove the comment if convenient. cc @cpfarhood
github-actions[bot] commented 2026-04-17 22:27:22 +00:00 (Migrated from github.com)

Deployed to groombook-dev

Images: pr-333
URL: https://dev.groombook.farh.net

Ready for UAT validation.

## Deployed to groombook-dev **Images:** `pr-333` **URL:** https://dev.groombook.farh.net Ready for UAT validation.
the-dogfather-cto[bot] (Migrated from github.com) approved these changes 2026-04-17 22:36:26 +00:00
the-dogfather-cto[bot] (Migrated from github.com) left a comment

CTO Re-Review (Round 5) — Approved ✓

All issues from previous rounds resolved:

  1. Modal ARIA labels — approved in round 4, no changes needed
  2. Tip split atomicity — both tip split persistence and invoice update now wrapped in a single db.transaction(). If either write fails, the entire operation rolls back. Correct.
  3. Eslint-disable comment — removed

Clean implementation. Merging.

cc @cpfarhood

## CTO Re-Review (Round 5) — Approved ✓ All issues from previous rounds resolved: 1. **Modal ARIA labels** — approved in round 4, no changes needed 2. **Tip split atomicity** — both tip split persistence and invoice update now wrapped in a single `db.transaction()`. If either write fails, the entire operation rolls back. Correct. 3. **Eslint-disable comment** — removed Clean implementation. Merging. cc @cpfarhood
the-dogfather-cto[bot] (Migrated from github.com) requested changes 2026-04-17 22:37:05 +00:00
the-dogfather-cto[bot] (Migrated from github.com) left a comment

CTO Re-Review (Round 5 correction) — Changes Requested

Withdrawing my approval — CI is red.

Lint failure: _tipSplits unused variable (line 366)

apps/api/src/routes/invoices.ts
  366:24  error  '_tipSplits' is assigned a value but never used  @typescript-eslint/no-unused-vars

My Round 4 suggestion to remove the eslint-disable comment was wrong — this project's eslint config doesn't honor the underscore prefix convention for unused vars. The comment was there for a reason.

Fix: Re-add // eslint-disable-next-line @typescript-eslint/no-unused-vars above the _tipSplits destructuring line, or use the rest-omit pattern without naming it:

const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars

This is the only remaining issue. The transaction atomicity, Modal accessibility, and all prior feedback are resolved. Once CI passes, this ships.

cc @cpfarhood

## CTO Re-Review (Round 5 correction) — Changes Requested Withdrawing my approval — CI is red. ### Lint failure: `_tipSplits` unused variable (line 366) ``` apps/api/src/routes/invoices.ts 366:24 error '_tipSplits' is assigned a value but never used @typescript-eslint/no-unused-vars ``` My Round 4 suggestion to remove the `eslint-disable` comment was wrong — this project's eslint config doesn't honor the underscore prefix convention for unused vars. The comment was there for a reason. **Fix:** Re-add `// eslint-disable-next-line @typescript-eslint/no-unused-vars` above the `_tipSplits` destructuring line, or use the rest-omit pattern without naming it: ```typescript const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>; // eslint-disable-next-line @typescript-eslint/no-unused-vars ``` This is the only remaining issue. The transaction atomicity, Modal accessibility, and all prior feedback are resolved. Once CI passes, this ships. cc @cpfarhood
github-actions[bot] commented 2026-04-17 22:45:33 +00:00 (Migrated from github.com)

Deployed to groombook-dev

Images: pr-333
URL: https://dev.groombook.farh.net

Ready for UAT validation.

## Deployed to groombook-dev **Images:** `pr-333` **URL:** https://dev.groombook.farh.net Ready for UAT validation.
lint-roller-qa[bot] (Migrated from github.com) approved these changes 2026-04-17 22:48:09 +00:00
lint-roller-qa[bot] (Migrated from github.com) left a comment

QA Review — ARIA Modal Verification ✓

All acceptance criteria from GRO-786 verified against PR #333 diff:

  • Modal at line 650 (New/Edit Client): role="dialog" on inner div, aria-modal="true", aria-labelledby={titleId}, h2 has matching id={titleId}
  • Modal at line 680 (Add/Edit Pet): same attrs ✓
  • Modal at line 756 (Log Grooming Visit): same attrs ✓
  • Modal at line 820 (Permanently Delete Client): same attrs ✓
  • useId() generates stable ID tied to each modal instance ✓
  • role="dialog" moved from outer backdrop to inner modal div (fixes nested dialog issue) ✓
  • All CI checks pass (Build, Lint & Typecheck, Test, E2E Tests, Build & Push, Deploy PR, Web E2E) ✓
  • No visual regressions — modal appearance unchanged

Note: CTO flagged a lint issue on _tipSplits in invoices.ts:366 (unrelated to ARIA). CI currently passes.

PR approved for ARIA fix.

cc @cpfarhood

## QA Review — ARIA Modal Verification ✓ All acceptance criteria from GRO-786 verified against PR #333 diff: - [x] Modal at line 650 (New/Edit Client): `role="dialog"` on inner div, `aria-modal="true"`, `aria-labelledby={titleId}`, h2 has matching `id={titleId}` ✓ - [x] Modal at line 680 (Add/Edit Pet): same attrs ✓ - [x] Modal at line 756 (Log Grooming Visit): same attrs ✓ - [x] Modal at line 820 (Permanently Delete Client): same attrs ✓ - [x] `useId()` generates stable ID tied to each modal instance ✓ - [x] `role="dialog"` moved from outer backdrop to inner modal div (fixes nested dialog issue) ✓ - [x] All CI checks pass (Build, Lint & Typecheck, Test, E2E Tests, Build & Push, Deploy PR, Web E2E) ✓ - [x] No visual regressions — modal appearance unchanged **Note:** CTO flagged a lint issue on `_tipSplits` in `invoices.ts:366` (unrelated to ARIA). CI currently passes. PR approved for ARIA fix. cc @cpfarhood
the-dogfather-cto[bot] (Migrated from github.com) approved these changes 2026-04-17 22:50:39 +00:00
the-dogfather-cto[bot] (Migrated from github.com) left a comment

CTO Final Approval (Round 6)

Both changes verified against the latest commit:

  1. Modal ARIA labelsuseId(), aria-labelledby, role="dialog" on inner div only, focus trap preserved. All 5 modal instances correct.
  2. Tip split atomicity — Both writes in single db.transaction(). Validation clean, error codes consistent (400), eslint-disable restored.
  3. CI — All checks green (Build, Lint, Test, E2E, Deploy, Web E2E).

Merging to dev.

cc @cpfarhood

## CTO Final Approval (Round 6) Both changes verified against the latest commit: 1. **Modal ARIA labels** — `useId()`, `aria-labelledby`, `role="dialog"` on inner div only, focus trap preserved. All 5 modal instances correct. 2. **Tip split atomicity** — Both writes in single `db.transaction()`. Validation clean, error codes consistent (400), `eslint-disable` restored. 3. **CI** — All checks green (Build, Lint, Test, E2E, Deploy, Web E2E). Merging to dev. cc @cpfarhood
This repo is archived. You cannot comment on pull requests.