Skip to content

Finish the audience surface: wire dead controls + session auth#660

Open
jaeyunha wants to merge 8 commits into
mainfrom
feat/audience-dead-controls
Open

Finish the audience surface: wire dead controls + session auth#660
jaeyunha wants to merge 8 commits into
mainfrom
feat/audience-dead-controls

Conversation

@jaeyunha

Copy link
Copy Markdown
Member

What

UltraQA of the Audience surface found it was broken at two layers: dead UI controls and mutation APIs that rejected dashboard logins. This finishes it.

The root problem

The Audience dashboard was scaffolded UI-first and API-key-first, so per-row actions were dead stubs and the detail routes only accepted Bearer API keys — a logged-in dashboard user (session cookie, no key) got 401 on every Edit/Delete. The unit tests were green throughout because they asserted rendering, not behavior.

Changes (5 commits)

  1. API auth unblockercontacts/[id], segments/[id], topics/[id], properties/[id] now accept a dashboard session OR an API key (authorizeDashboardOrApiKey), matching /api/contacts POST.
  2. Contact Edit/Delete — were onEdit={() => {}} no-ops; now a real edit modal (PATCH) + delete confirm (DELETE).
  3. Segment counts — the list rendered contactsCount/unsubscribedCount but the API never returned them (and the created date was mis-mapped); now computed from the join table and mapped correctly.
  4. Row ⋮ menus — all four list tables had a dead button; now a reusable RowActionsMenu with working Delete + confirm.
  5. Topics layout — split-pane (list left, live unsubscribe-page preview right), matching Resend.

Tests

Behavioral coverage added for the new wiring (each fails on the prior dead version): contact edit/delete, the row menu, segment counts. Full suite: 1731 passed, 0 failed; make check clean. Public segments API doc updated + docs:generate.

Note

Discovered during QA prompted by the CSV-import work (#659, merged). These are pre-existing gaps, not regressions.

🤖 Generated with Claude Code

jaeyunha and others added 5 commits June 17, 2026 23:08
contacts/[id], segments/[id], topics/[id], and properties/[id] all
authenticated with validateApiKey only, so their GET/PATCH/DELETE
handlers returned 401 for a logged-in dashboard user (session cookie,
no Bearer key). This made every Edit/Delete action in the Audience UI
non-functional regardless of whether the buttons were wired.

Switch all four route families to authorizeDashboardOrApiKey + a
resolveUserId helper (session cookie OR full-access API key), matching
the pattern already used by /api/contacts POST and the CSV import route.

Update the route tests that previously asserted API-key-only behavior to
expect session-or-key auth, and re-establish the new auth mock after the
mid-test vi.resetAllMocks() in the contact tenant-isolation suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contact detail page rendered an actions dropdown but passed
onEdit={() => {}} and onDelete={() => {}} — both no-ops. Clicking
Edit/Delete did nothing.

Add an EditContactModal (first/last name + unsubscribed toggle) that
PATCHes /api/contacts/[id], and a delete confirmation dialog that
DELETEs the contact and routes back to /audience. Both rely on the
dashboard session cookie now that the detail route accepts it.

Tests: behavioral cases that open the edit modal with seeded values and
assert the delete API is called on confirm — both fail on the prior
dead handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The segments list rendered seg.contactsCount / seg.unsubscribedCount and
seg.createdAt, but GET /api/segments returned only {id, name, created_at}
— so the Contacts/Unsubscribed columns were blank and the created date
was mis-mapped (snake_case created_at vs camelCase createdAt).

- segmentRepo.listForApi now computes contactsCount and unsubscribedCount
  per segment via subqueries on the contacts_to_segments join table.
- toSegmentListItem exposes contacts_count and unsubscribed_count.
- segments-list maps the snake_case API response to its camelCase shape,
  fixing the counts and the created date together.

Docs + tests updated to the new response shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every audience list table (contacts, segments, topics, properties)
rendered a row "⋮ More actions" button with no onClick — a dead control.

Add a reusable RowActionsMenu (dropdown + confirm dialog) and wire it
into all four lists with a working Delete action against the now
session-aware detail routes, refreshing the list on success. Contacts
also get a View / edit shortcut to the detail page.

Tests: behavioral coverage for the menu opening, running a non-destructive
action, and requiring confirmation before delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The topics page stacked the unsubscribe-page preview far below the
topics list, so the two were never visible together. Match the
Resend-style split layout: topics list on the left, the live
unsubscribe-page preview on the right (sticky on wide screens),
collapsing to a single column on narrow viewports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d29245432

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +53 to +54
const res = await fetch(`/api/contacts/${contact.id}`, {
method: "PATCH",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a lookup that can handle contact UUIDs

When saving a normal persisted contact whose contact.id is a UUID, this PATCH goes through contactService().updateContact()findByIdOrEmailForUser(). That helper currently only recognizes a non-standard four-part UUID regex, so a real DB UUID is treated as an email address and the route returns Contact not found; the newly wired edit modal therefore cannot save most contacts until the ID lookup is fixed or this path uses an identifier the service can resolve.

Useful? React with 👍 / 👎.

Comment on lines +68 to +70
contactsCount: sql<number>`(
select count(*) from ${contactsToSegments}
where ${contactsToSegments.segmentId} = ${segments.id}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count segment members from the persisted membership source

For contacts assigned to segments through the existing POST /api/contacts, bulk-add, or import flows, membership is persisted in contacts.segments and those flows do not insert rows into contacts_to_segments. Reading counts only from the join table makes the new contacts_count/dashboard segment totals show zero or stale values for those common assignment paths, even though the same contacts still filter/list as segment members elsewhere.

Useful? React with 👍 / 👎.

Comment on lines +45 to +47
const auth = await authorizeDashboardOrApiKey(
_request.headers.get("authorization"),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep root segment aliases API-key-only

src/app/segments/[id]/route.ts re-exports this handler for the public /segments/:id alias, so switching the shared detail handler to authorizeDashboardOrApiKey makes GET/DELETE /segments/:id succeed with only a Better Auth dashboard cookie. That violates the root segment alias contract that public API paths require an API key; split the dashboard-session adapter from the root alias or keep the root export on API-key-only auth.

Useful? React with 👍 / 👎.

@jaeyunha

Copy link
Copy Markdown
Member Author

Hermes maintainer review

Verdict: changes requested. CI is green, but the diff has a few real product/accessibility defects that should be fixed before this main-target PR moves.

Blocking findings

  1. Topics list consumes the wrong API shape

    • src/components/topics-list.tsx stores data.data directly, but AudienceMetadataService.listTopics returns snake_case fields (default_subscription, created_at, updated_at). The UI reads camelCase (defaultSubscription, createdAt), so default subscription labels, date rendering, and the new unsubscribe-page preview can be wrong.
  2. Properties list consumes the wrong API shape

    • src/components/properties-list.tsx stores data.data directly, while the service returns fallback_value, created_at, updated_at. Fallback values and created dates can render incorrectly.
  3. Row action trigger is keyboard-invisible

    • src/components/row-actions-menu.tsx uses opacity-0 until hover/open while the trigger remains tabbable. Keyboard users can focus an invisible destructive-actions button. Make it visible on focus/focus-within too.
  4. New modal/confirm flows need basic dialog accessibility

    • RowActionsMenu confirm, ContactDetail delete confirm, and EditContactModal need at least role="dialog", aria-modal, a labelled heading/id, sane initial focus, and Escape handling where missing.
  5. Segment counts should be tenant-hardened

    • packages/core/src/db/repositories/segmentRepo.ts new aggregate subqueries count by segment id but do not constrain counted contacts to the same tenant/user. Normal service paths probably prevent bad joins, but the aggregate should still include contacts.user_id = options.userId so stale/corrupt cross-tenant join rows cannot inflate/leak counts.

Evidence

  • Reviewed diff for PR head 5d29245432913d70a1bd579db133a1d17946ad36 against origin/main.
  • GitHub CI is green: Typecheck, Lint, Unit tests, Migration parity, SDK tests, Onboarding acceptance, PR checks.
  • Backend targeted validation from review pass: git diff --check origin/main...pr-660-review passed; bun run test tests/audience-metadata-routes.test.ts tests/audience-metadata-service.test.ts tests/contact-tenant-isolation.test.ts passed (32 tests).

I spawned a follow-up Clawhip lane in opensend-pr660-audience-fixes to fix these on the existing PR branch; no new PR needed.

@jaeyunha

Copy link
Copy Markdown
Member Author

Addressed the Hermes review feedback on this existing PR branch.

Summary:

  • Normalized topic/property API payloads before rendering so snake_case fields drive labels, dates, fallback values, and topic preview state correctly.
  • Made row action triggers visible on keyboard focus/focus-within while preserving hover/open behavior.
  • Added labelled modal dialog semantics, aria-modal, initial focus, and Escape-close coverage for row-action confirm, contact delete confirm, and edit contact flows.
  • Hardened segment aggregate counts to count only contacts owned by the requested tenant/user.

Head SHA: 68ccbf16bcf38462bf8a011ac0ab0d4701afcf2b

Validation:

  • bun run test tests/topics.test.ts tests/properties.test.tsx tests/row-actions-menu.test.tsx tests/contact-detail.test.tsx tests/segment-repo-tenant-counts.test.ts tests/audience-metadata-routes.test.ts tests/audience-metadata-service.test.ts tests/contact-tenant-isolation.test.ts tests/segment-root-api.test.ts — 66 passed
  • git diff --check origin/main...HEAD — passed
  • make check — passed
  • make test — 214 files passed, 3 skipped; 1734 tests passed, 10 skipped

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68ccbf16bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +62 to +64
const auth = await authorizeDashboardOrApiKey(
request.headers.get("authorization"),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep root contact aliases API-key-only

When this handler is reached through src/app/contacts/[contact_id]/route.ts, a logged-in dashboard browser can now call GET/PATCH/DELETE /contacts/:contact_id with only the Better Auth cookie because this path resolves dashboard sessions instead of requiring validateApiKey. Those root contact routes are the public SDK/API aliases, so this changes them from API-key-only to cookie-authenticated; split the dashboard /api/contacts/:id handler from the root alias or keep API-key validation in the alias path.

Useful? React with 👍 / 👎.

Comment thread src/app/api/topics/[id]/route.ts Outdated
Comment on lines +77 to +79
const auth = await authorizeDashboardOrApiKey(
request.headers.get("authorization"),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep root topic detail aliases API-key-only

For public compatibility requests such as PATCH /topics/:id or DELETE /topics/:id, src/middleware.ts rewrites the root alias to this /api/topics/:id handler and tags it as a root alias. After this auth switch, a dashboard session cookie is enough to mutate topic records without any Authorization header, so the public topic alias no longer enforces API-key auth; route root aliases through an API-key-only adapter or reject dashboard-only auth when the root-alias header is present.

Useful? React with 👍 / 👎.

Comment thread src/app/api/properties/[id]/route.ts Outdated
Comment on lines +77 to +79
const auth = await authorizeDashboardOrApiKey(
request.headers.get("authorization"),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep root property detail aliases API-key-only

For public compatibility requests such as PATCH /contact-properties/:id or DELETE /contact-properties/:id, src/middleware.ts rewrites the root alias to this /api/properties/:id handler and marks it as a root alias. With this new authorizeDashboardOrApiKey path, a dashboard cookie can now update or delete a contact property through the public alias without a Bearer key, so keep the root alias on API-key-only auth or split it from the dashboard handler.

Useful? React with 👍 / 👎.

@jaeyunha

Copy link
Copy Markdown
Member Author

Correction to the previous comment: shell interpolation stripped the inline code spans. Literal summary below.

Addressed the current Codex P2 blockers on PR #660.

Fixed:

  • Root contact detail aliases /contacts/:id GET/PATCH/DELETE now require API-key auth before delegating to the dashboard-compatible /api/contacts/:id handler.
  • Root segment detail aliases /segments/:id GET/DELETE now require API-key auth while /api/segments/:id still supports dashboard sessions.
  • Middleware root topic/property detail rewrites remain API-key-only via the root alias header for /topics/:id and /contact-properties/:id, including PATCH/DELETE.
  • Segment counts now include both contacts_to_segments and persisted contacts.segments memberships, scoped to the requested tenant and de-duped by contact id.
  • Verified/covered the persisted UUID edit-contact save path.

Head SHA: f3cf23d

Validation:

  • bun run test -- tests/contact-root-api.test.ts tests/segment-root-api.test.ts tests/audience-metadata-routes.test.ts tests/segment-repo-tenant-counts.test.ts tests/contact-detail.test.tsx tests/contact-service.test.ts tests/contact-operations-routes.test.ts tests/contact-root-relationships-api.test.ts — 8 files / 48 tests passed.
  • git diff --check origin/main...HEAD — passed.
  • make check — passed.
  • make test — passed (214 files, 1,739 tests; 3 files / 10 tests skipped by existing config).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3cf23d788

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};

function normalizeProperty(property: PropertyApiPayload): Property {
const type = property.type === "number" ? "number" : "string";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-string property types

When /api/properties returns properties created through the public/root API with supported type values like boolean or date (the audience metadata service explicitly accepts those enum values and returns row.type), this normalizer coerces everything except number to string. The dashboard will therefore display existing boolean/date contact properties as string properties, which can mislead operators reviewing their schema; keep the full set of supported types instead of collapsing them here.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant