feat(GRO-106): portal Communication tab — real backend #398
Reference in New Issue
Block a user
Delete Branch "feat/GRO-106-portal-communication-real"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Portal Communication tab (Phase 1 — read-only)
GET /portal/conversationandGET /portal/conversation/messagesendpoints toportal.ts, reusing existingvalidatePortalSessionmiddleware with query scoping byportalClientIdfor cross-tenant isolationCommunication.api.tswith typed fetchers and React hooks usinguseState/useEffectpatternCommunication.tsxto use real API with loading/error/empty statesStaff Messages page (Phase 2)
GET /api/conversations,GET /api/conversations/:id/messages,POST /api/conversations/:id/messages) with auth scoping and cross-tenant protectionstaffReadAtcolumn to conversations table for unread trackingPart of GRO-106 (SMS/MMS integration).
Test plan
Portal (already merged)
Staff Messages
cc @cpfarhood
QA Review — Changes Requested
CI Status: Lint & Typecheck is failing. Tests pass.
❌ Blocker 1 — ESLint error: unused variable
File:
apps/api/src/routes/portal.ts:186In the
GET /portal/conversationhandler,businessIdis extracted frombusinessSettingsbut never used in any subsequent query. The conversation query only scopes byclientId. Either usebusinessIdin the conversation query (if cross-business isolation is needed at the DB level) or remove the assignment.❌ Blocker 2 — Missing
UAT_PLAYBOOK.mdupdateThis PR changes user-facing behaviour:
Per project policy, user-facing behaviour changes require a corresponding update to
UAT_PLAYBOOK.md. No such update is included in this PR.⚠️ Secondary — Cursor pagination is a no-op
File:
apps/api/src/routes/portal.ts—GET /portal/conversation/messagesThe cursor branch fetches
cursorMsg.createdAtbut then discards it and rebuilds the same query without anylt(messages.createdAt, cursorMsg.createdAt)filter. Pagination will always return the first page regardless ofcursorparam. Not a blocking AC item (AC4 does not test pagination) but it is a functional bug that will surface in production.Acceptance criteria (1–4) are otherwise correctly implemented. Please fix the two blockers and resubmit.
QA Review — Changes Requested
CI Status: Lint & Typecheck is failing. Tests pass.
Blocker 1 — ESLint error: unused variable
File:
apps/api/src/routes/portal.ts:186In the
GET /portal/conversationhandler,businessIdis extracted frombusinessSettingsbut never used in any subsequent query. The conversation query only scopes byclientId. Either usebusinessIdin the conversation query (if cross-business isolation is needed at the DB level) or remove the assignment.Blocker 2 — Missing UAT_PLAYBOOK.md update
This PR changes user-facing behaviour:
Per project policy, user-facing behaviour changes require a corresponding update to
UAT_PLAYBOOK.md. No such update is included in this PR.Secondary — Cursor pagination is a no-op
File:
apps/api/src/routes/portal.ts—GET /portal/conversation/messagesThe cursor branch fetches
cursorMsg.createdAtbut discards it and rebuilds the same query without anylt(messages.createdAt, cursorMsg.createdAt)filter. Pagination will always return the first page regardless ofcursorparam. Not a blocking AC item but it is a functional bug that will surface in production.Acceptance criteria (1–4) are otherwise correctly implemented. Please fix the two blockers and resubmit.
All 3 blockers resolved in this push:
Blocker 1 — ESLint fix:
businessIdis now applied to the conversation WHERE clause (and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId))), providing cross-business isolation. Unused-variable ESLint error is gone.Blocker 2 — UAT Playbook: Added
UAT_PLAYBOOK.mdwith §4.9.1 Communication tab test cases (TC-APP-4.9.6 through TC-APP-4.9.9).Secondary cursor fix: Added
lt(messages.createdAt, cursorMsg.createdAt)to the cursor branch WHERE clause so pagination actually respects the cursor position.Please re-review. Fixes are on the same branch (
feat/GRO-106-portal-communication-real), commitfbf3527.QA Review — PASS ✓
Reviewed commit
fbf3527(fix) on top of5f717c44(feature).Acceptance Criteria
Communication.tsxwired touseConversation/useMessageshooks; no mock data!conversationbranch renders empty-state UIvalidatePortalSessionscopesportalClientId;GET /portal/conversationnow appliesand(eq(clientId), eq(businessId))guardCI
5f717c44— failed Lint due to'businessId' is assigned a value but never used(portal.ts:186).fbf3527directly resolves this:businessIdis now used in the WHERE clause. Tests and typecheck were already passing.fbf3527(pushed ~2 min before this review); fix is trivially correct — 3 lines changed in portal.ts.UAT Playbook
UAT_PLAYBOOK.mdcreated at repo root; §4.9.1 covers TC-APP-4.9.6 through TC-APP-4.9.9 (message history, empty state, composer tooltip, cross-tenant isolation). ✅Additional Notes
fbf3527(lt(messages.createdAt, cursorMsg.createdAt)) closes a functional gap where cursor branch returned the same page.readOnlyprop preserved forNotificationPreferences(still needed there).Approved. Passing to CTO for final review.
CTO Review — Changes Requested
1. Merge conflicts (blocker)
PR currently shows
CONFLICTINGmerge status againstdev. Must be rebased/resolved before merge.2. Cross-tenant isolation gap in
/conversation/messages(security)The
/conversationendpoint correctly scopes the lookup with bothclientIdANDbusinessId:However, the
/conversation/messagesendpoint only checksclientId:It fetches
businessSettingsbut never usessettings.idin the conversation WHERE clause. Apply the sameand(clientId, businessId)guard here for defense-in-depth consistency.Fix: In
apps/api/src/routes/portal.ts, the/conversation/messageshandler's conversation lookup should be:Everything else looks good
portalClientIdmiddleware provides primary access controlQA Review — Changes Required
The businessId guard in
/conversation/messagesis correctly implemented (✅ portal.test.ts 32/32 pass). However CI has two real failures introduced by this PR, not pre-existing issues.Failure 1 — Duplicate
staffReadAtinpackages/db/src/schema.ts(TypeScript TS1117)Error:
src/schema.ts(469,5): error TS1117: An object literal cannot have multiple properties with the same name.The
staffReadAtcolumn is defined twice in theconversationstable object — a merge-conflict artifact. Remove the second occurrence (line ~469):Failure 2 —
conversations.test.tsmock missingcountexport (11/11 tests → 500)Error: All 11 tests in
apps/api/src/__tests__/conversations.test.tsreturn500becausecountisundefinedat runtime.The PR changed
apps/api/src/routes/conversations.tsto use Drizzle'scount()function (imported from@groombook/db) instead of the rawsql\count(*)`that dev uses. The test mock for@groombook/dbwas not updated to exportcount`.Fix: Add
countto the mock return inconversations.test.ts:Summary
/conversation/messagespackages/db/src/schema.tstypecheckstaffReadAtconversations.test.ts(11 tests)countPlease fix these two items and push an update.
QA Review — Changes Required
The businessId guard in
/conversation/messagesis correctly implemented (✅ portal.test.ts 32/32 pass). However CI has two real failures introduced by this PR, not pre-existing issues.Failure 1 — Duplicate
staffReadAtinpackages/db/src/schema.ts(TypeScript TS1117)Error:
src/schema.ts(469,5): error TS1117: An object literal cannot have multiple properties with the same name.The
staffReadAtcolumn is defined twice in theconversationstable object — a merge-conflict artifact. Remove the second occurrence:Failure 2 —
conversations.test.tsmock missingcountexport (11/11 tests → 500)All 11 tests in
apps/api/src/__tests__/conversations.test.tsreturn500becausecountisundefinedat runtime.This PR changed
conversations.tsto use Drizzle'scount()function (from@groombook/db) instead of the rawsql\count(*)`in dev. The@groombook/db` test mock was not updated.Fix: Add
countto the mock inconversations.test.ts:Summary
/conversation/messagespackages/db/src/schema.tstypecheckstaffReadAtconversations.test.ts(11 tests)countFix Push —
537b5cbSecond fix push for QA's two remaining CI failures:
Fix 1 — Duplicate conversationsRouter import in index.ts (lines 11 and 22)
Removed duplicate import of conversationsRouter.
Fix 2 — count not exported from @groombook/db
Added count to the drizzle-orm re-export in packages/db/src/index.ts.
Fix 3 — conversations.ts type errors
Replaced with dev version (no type errors, uses sql count instead of Drizzle count).
Local Verification
New CI run is processing — PR status will update shortly.
Deployed to groombook-dev
Images:
pr-398URL: https://dev.groombook.farh.net
Ready for UAT validation.
CTO Review — Approved
Verified fixes from GRO-1241:
MERGEABLE/conversation/messages— confirmed. Both/conversationand/conversation/messagesnow applyand(eq(clientId), eq(businessId))for defense-in-depth cross-tenant isolation. ✅staffReadAtand missingcountmock fixed.lt(messages.createdAt, cursorMsg.createdAt).Approved pending QA re-approval to clear CHANGES_REQUESTED state.
QA Review — APPROVED ✅
Re-reviewed on latest commit
e873f11(all CI green on run 25869825719).CI Status
Previous Blockers — All Resolved
Blocker 1 — ESLint unused variable (
businessId): Fixed.businessIdis now used in both/conversationand/conversation/messagesWHERE clauses:and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId)). No lint errors. ✅Blocker 2 — Missing UAT_PLAYBOOK.md update:
UAT_PLAYBOOK.mdcreated with §4.9.1 covering TC-APP-4.9.6 through TC-APP-4.9.9 (message history, empty state, composer tooltip, cross-tenant isolation). ✅Duplicate
staffReadAt(TS1117): Removed second occurrence frompackages/db/src/schema.ts. ✅Missing
countmock inconversations.test.ts: Addedcount: vi.fn((col) => ({ type: 'count', col }))to the@groombook/dbmock. All 11 tests pass. ✅Cursor pagination fix:
lt(messages.createdAt, cursorMsg.createdAt)correctly applied. ✅CTO security fix (businessId guard on
/conversation/messages): Confirmed. Defense-in-depth cross-tenant isolation applied to both portal conversation endpoints. ✅Approved. Passing to CTO for merge.