Skip to content

beta access flow - #12

Merged
flvvius merged 4 commits into
mainfrom
feat/beta-access
Apr 26, 2026
Merged

beta access flow#12
flvvius merged 4 commits into
mainfrom
feat/beta-access

Conversation

@flvvius

@flvvius flvvius commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • Early access application card and required-access flow for gated pages (events, feed, dashboard)
    • Invite preview/validation and admin invite/send (single & batch) for waitlist management
    • Admin waitlist overview with stats and bounded lists
    • Google social sign-in UI and auth divider
  • Improvements

    • Flexible sign-in/sign-up forms (configurable copy, redirect, email locking)
    • Normalized email handling and updated invite email templates
    • Accessibility tweaks to loading placeholders

@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
news Error Error Apr 26, 2026 8:12am
news-web Ready Ready Preview, Comment Apr 26, 2026 8:12am

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@flvvius has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 50 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 16 minutes and 50 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b0d849d3-65e7-48b5-9423-c1487168ed4d

📥 Commits

Reviewing files that changed from the base of the PR and between 7d56055 and 05f231a.

📒 Files selected for processing (15)
  • apps/web/src/components/auth-social.tsx
  • apps/web/src/components/early-access-required.tsx
  • apps/web/src/components/sign-in-form.tsx
  • apps/web/src/components/sign-up-form.tsx
  • apps/web/src/lib/auth-redirect.ts
  • apps/web/src/lib/beta-welcome.ts
  • apps/web/src/routes/dashboard.tsx
  • apps/web/src/routes/event.$slug.tsx
  • apps/web/src/routes/feed.tsx
  • packages/backend/convex/auth.ts
  • packages/backend/convex/emails.ts
  • packages/backend/convex/events.ts
  • packages/backend/convex/lib/betaAccess.ts
  • packages/backend/convex/migrations.ts
  • packages/backend/convex/waitlist.ts

Walkthrough

Adds beta-access gating across frontend routes, new early-access UI components, configurable auth forms, backend waitlist/admin invite workflows, email invite plumbing, and shared beta-access utilities (email normalization, admin allowlist, access checks, and enforcement helpers).

Changes

Cohort / File(s) Summary
Early Access UI
apps/web/src/components/early-access-apply-card.tsx, apps/web/src/components/early-access-required.tsx
New apply card and required-access UI. Apply card posts normalized email/name to waitlist mutation and surfaces success/error and position; required component composes messaging, sign-in/out, and conditionally renders the apply card or info.
Auth Forms & Social
apps/web/src/components/sign-in-form.tsx, apps/web/src/components/sign-up-form.tsx, apps/web/src/components/auth-social.tsx
Sign-in/up forms made prop-configurable (initialEmail, emailLocked, redirectTo, title/subtitle, submitLabel, showGoogle, optional switch handlers). Adds AuthDivider and GoogleSignInButton for conditional Google sign-in flows.
Route Beta Gating
apps/web/src/routes/dashboard.tsx, apps/web/src/routes/event.$slug.tsx, apps/web/src/routes/feed.tsx
Client-side beta access checks added. Dashboard gains invite preview handling, two-column onboarding, and admin waitlist management UI; event and feed routes block rendering with EarlyAccessRequired when access missing.
Landing / Previews
apps/web/src/routes/index.tsx, packages/backend/convex/events.ts
Landing preview query switched to new preview API (getPublicPublishedEventsPreview). Backend events API refactored: beta gating enforced, enrichment helper extracted, and preview queries added (getPublicPublishedEventsPreview, getEventBySlugPreview).
Backend Beta Access Library
packages/backend/convex/lib/betaAccess.ts, packages/backend/convex/user.ts
New shared beta-access helpers: normalizeEmail, admin-email cache, waitlist lookup, beta-access calculation, and enforcement helpers (requireBetaAccess, requireAdminUser). User query getCurrentUserAccess added; admin check delegated to library.
Waitlist & Invite Admin
packages/backend/convex/waitlist.ts, packages/backend/convex/emails.ts, packages/backend/convex/auth.ts
Waitlist handlers now use normalized email lookups, add admin overview and invite-preview queries, and introduce mutations to invite a user or batch-invite pending users (with RESEND_API_KEY). Email send uses runtime SITE_URL, requires waitlistId, and records send; auth triggers normalize emails and auto-convert matching waitlist entries.
Admin Auth Integration
packages/backend/convex/config.ts, packages/backend/convex/clustering.ts
Files replaced local admin-allowlist logic with imported requireAdminUser from betaAccess, consolidating admin auth checks across handlers.
Convex Schema
packages/backend/convex/schema.ts
Adds a new Convex index on waitlist for status/invitedAt queries.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Client as Client App
    participant Server as Backend
    participant DB as Database

    User->>Client: Submit early access form
    Client->>Client: Normalize email (trim + normalizeEmail)
    Client->>Server: addToWaitlist({ email, name? })
    Server->>DB: Query waitlist by normalized email (by_email)
    alt already exists
        DB-->>Server: existing record (position, status)
        Server-->>Client: alreadyExists + position
    else new entry
        DB->>DB: Insert waitlist record (status: pending)
        Server-->>Client: Success with new position
    end
    Client->>Client: Update UI (success/error, reset inputs)
Loading
sequenceDiagram
    participant Client as Client App
    participant Server as Backend
    participant DB as Database
    participant Email as Email Service
    participant Admin as Admin User

    Admin->>Client: Trigger inviteNextPendingUsers(count)
    Client->>Server: inviteNextPendingUsers(count, auth)
    Server->>Server: requireAdminUser(ctx)
    Server->>DB: Query next pending waitlist entries (limit)
    DB-->>Server: pending records
    loop per pending user
        Server->>DB: Patch waitlist entry (inviteCode, invitedAt, status=invited)
        Server->>Email: sendInviteEmail(waitlistId, inviteCode, email)
        Email-->>Server: send success
        Server->>DB: markEmailSent(waitlistId)
    end
    Server-->>Client: invited emails + counts
Loading
sequenceDiagram
    participant Client as Client App
    participant Server as Backend
    participant DB as Database

    Client->>Server: getCurrentUserAccess()
    Server->>Server: safeGetAuthUser()
    alt unauthenticated
        Server-->>Client: unauthenticated access payload
    else authenticated
        Server->>DB: getWaitlistRecordByEmail(normalizeEmail(authUser.email))
        DB-->>Server: waitlist record or null
        Server->>Server: compute isAdmin / hasBetaAccess
        Server-->>Client: access payload (hasBetaAccess, waitlist metadata)
    end
    alt hasBetaAccess
        Client->>Client: Render protected content
    else no access
        Client->>Client: Render EarlyAccessRequired -> shows apply card or sign-in
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'beta access flow' directly relates to the main purpose of the changeset, which implements a complete beta access gating system across frontend and backend.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/beta-access

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/backend/convex/events.ts (2)

89-138: 🧹 Nitpick | 🔵 Trivial

Extract the shared event-enrichment logic.

The eventTopics map build + articles/sources enrichment in getPublishedEvents (lines 89–132) is now duplicated almost verbatim in getPublicPublishedEventsPreview (lines 153–187). Pulling it into a helper like enrichEventsWithTopicsAndSources(ctx, events) keeps the two queries from drifting and makes any future change (e.g. field projection per the comment above) a one-liner.

Also applies to: 153-187

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/convex/events.ts` around lines 89 - 138, Extract the
duplicated enrichment logic in getPublishedEvents and
getPublicPublishedEventsPreview into a shared helper
enrichEventsWithTopicsAndSources(ctx, events): move the eventTopics preloading
(building topicsByEventId from eventIds and allEventTopicRows) and per-event
article/source/topic enrichment (loading articles, computing articleCount,
deduplicating sourceIds and loading sources, mapping topicIds from
topicsByEventId, filtering null sources) into that helper; update both functions
to call enrichEventsWithTopicsAndSources(ctx, events) and return the same shape
({ ...events, page: enrichedPage }) so future changes (e.g. projection) are
centralized.

191-200: ⚠️ Potential issue | 🟠 Major

Beta-gating getEventBySlug regresses SSR meta tags for every event detail page.

apps/web/src/routes/event.$slug.tsx calls this query through serverHttpClient from the route loader to populate dynamic OG/Twitter/canonical meta in head(). With requireBetaAccess(ctx) enforced here, SSR contexts without authentication (search crawlers, link unfurlers) will throw ConvexError. The loader's try/catch silently catches it and returns null, causing the page to ship generic Event — Biviant meta to all anonymous clients.

Additionally, getPublicPublishedEventsPreview spreads ...event, exposing every column from the events table to anonymous callers — including fields that may not be intended for public consumption.

Fix options:

  1. Create a public SEO-safe slice (e.g., getEventBySlugPreview returning { title, perspectiveSummaries.center, imageUrl, imageAlt }) for the SSR loader, keeping the gated getEventBySlug for authenticated detail UI.
  2. If beta is intentionally private, emit noindex from the loader and accept generic meta.
  3. Audit and limit fields returned from getPublicPublishedEventsPreview to prevent unintended data exposure.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/convex/events.ts` around lines 191 - 200, The beta check in
getEventBySlug causes SSR callers to get blocked; implement a public, SEO-safe
slice (e.g., getEventBySlugPreview) that does NOT call requireBetaAccess and
returns only allowed fields (title, perspectiveSummaries.center, imageUrl,
imageAlt, canonical slug, and any minimal SEO fields) for use by the route
loader/serverHttpClient; keep the existing getEventBySlug with requireBetaAccess
for the authenticated detail UI. Also audit and restrict
getPublicPublishedEventsPreview so it no longer spreads ...event but explicitly
selects only safe public columns to avoid leaking sensitive fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/early-access-required.tsx`:
- Line 42: The unauthenticated copy for the early access message is awkwardly
phrased; update the string that uses surfaceName (the message template
referencing surfaceName in the EarlyAccessRequired component) to replace "from
your beta email" with "from your invite email" so it reads: "<surfaceName> is
currently available to invited beta users. Sign in with the invited email from
your invite email, or apply for access below." — adjust only the phrasing around
surfaceName in that message template.

In `@apps/web/src/components/sign-up-form.tsx`:
- Around line 184-229: Extract the duplicated Google sign-in divider +
button+SVG into two reusable components: AuthDivider (renders the horizontal
divider with "Or continue with") and GoogleSignInButton which accepts a
callbackURL prop and calls authClient.signIn.social({ provider: "google",
callbackURL }) on click and contains the inline SVG and button styling; replace
the duplicated blocks in both SignUpForm and SignInForm with <AuthDivider /> and
<GoogleSignInButton callbackURL={redirectTo} /> respectively so the SVG and
click logic live in one place.

In `@apps/web/src/routes/dashboard.tsx`:
- Around line 638-689: The UI uses a single isInvitingWaitlist flag so every row
and batch button shows "Sending..." and is disabled simultaneously; change to a
per-row in-flight id: add state like invitingWaitlistId (string | null) and set
it in handleInviteSingleUser before calling the mutation and clear it in finally
(or use the mutation's onMutate/onSettled hooks), then update the per-row button
disabled/label logic to disabled={isInvitingWaitlist && invitingWaitlistId !==
entry._id ? true : (invitingWaitlistId === entry._id)} and label to
(invitingWaitlistId === entry._id ? "Sending..." : "Send invite"). Keep existing
isInvitingWaitlist (or a separate isBatchInviting) for the batch handlers
handleInviteNextPendingUsers so batch buttons still disable all rows during
batch operations.
- Around line 287-295: Replace direct calls to convex's useMutation for
inviteWaitlistUser and inviteNextPendingUsers by first wrapping the Convex
mutation with useConvexMutation (e.g., const convexInviteWaitlistUser =
useConvexMutation(api.waitlist.inviteWaitlistUser)) and then pass that into
TanStack's useMutation (e.g., useMutation({ mutationFn: convexInviteWaitlistUser
})). Remove the manual isInvitingWaitlist state and any manual pending/error
handling around inviteNextPendingUsers/inviteWaitlistUser, and update UI/buttons
to use the TanStack mutation state flags (inviteNextPendingUsers.isPending /
inviteWaitlistUser.isPending, isError, error, reset) instead. Ensure you update
references to the original functions (inviteWaitlistUser,
inviteNextPendingUsers) to the new TanStack-wrapped mutation objects so existing
call sites use mutation.mutate(...) and state flags rather than custom useState
flags.

In `@apps/web/src/routes/feed.tsx`:
- Around line 224-234: The loading placeholder that renders when the variable
access is undefined should be an accessible ARIA live region so screen readers
announce the status; update the inner placeholder element (the div with class
"rounded-[1.2rem] border border-border/70 bg-card/70 px-5 py-8 text-sm
text-muted-foreground" in apps/web/src/routes/feed.tsx) to include role="status"
and aria-live="polite" so the "Loading…" message is announced.

In `@packages/backend/convex/emails.ts`:
- Around line 128-129: The current fallback uses process.env.SITE_URL ??
DEFAULT_SITE_URL which does not handle empty-string env values; change the
SITE_URL resolution to treat empty or whitespace-only values as missing (e.g.,
use process.env.SITE_URL?.trim() ? process.env.SITE_URL.trim() :
DEFAULT_SITE_URL or the || pattern) when setting siteUrl and constructing
inviteUrl (variable inviteUrl) to ensure a valid base URL; also stop reading
process.env inside the email template by passing the resolved siteUrl into
getInviteEmailHTML (or whichever function builds the template) so both the
invite link and footer href use the same validated siteUrl.

In `@packages/backend/convex/lib/betaAccess.ts`:
- Around line 13-22: getAdminEmails currently reparses process.env.ADMIN_EMAILS
on every call; introduce a module-level lazy cache (e.g., let cachedAdminEmails:
Set<string> | null = null) and populate it the first time getAdminEmails or
isAdminEmail is called by splitting, normalizing (via normalizeEmail), trimming
and storing in a Set<string>, then have getAdminEmails return
Array.from(cachedAdminEmails) or isAdminEmail consult
cachedAdminEmails.has(normalizeEmail(email)); ensure the cache is only populated
once and handles an empty env string safely.
- Around line 91-102: Replace the duplicated admin-check implementations by
using the centralized functions in betaAccess.ts: import and call
requireAdminUser (and/or getAdminEmails if needed) instead of the private
requireAdmin() in config.ts and the private requireAdminUser() in clustering.ts;
remove the inline ADMIN_EMAILS parsing blocks from config.ts and clustering.ts,
update all callers (the listed call sites) to use the imported requireAdminUser,
and in clustering.ts ensure any thrown error uses ConvexError rather than Error
to match Convex conventions.

In `@packages/backend/convex/waitlist.ts`:
- Around line 281-287: The current query in inviteNextPendingUsers loads all
pending rows via ctx.db.query("waitlist").withIndex("by_status", (q) =>
q.eq("status", "pending")).order("asc").collect() and then slices to count,
which wastes reads and can hit limits; change it to use .take(count) directly on
that query to return only the first count rows (i.e. replace .collect() +
slice(0, count) with .take(count)) so the function retrieves just the needed
documents without loading the entire pending pool.
- Around line 187-216: getWaitlistAdminOverview currently calls
ctx.db.query("waitlist").collect() and then filters/sorts in JS; change it to
use index-bounded queries: fetch nextPending with
ctx.db.query("waitlist").by_status("pending").take(safeLimit) (or equivalent
index API) and fetch recentInvites with a new index by_status_invitedAt and
.by_status_invitedAt("invited").take(safeLimit) so you never load the whole
table into memory; for stats, stop scanning the full table—introduce a small
waitlistStats counters table (waitlistStats) updated by addToWaitlist,
inviteWaitlistUser, inviteNextPendingUsers, and unsubscribe to return O(1)
counts in getWaitlistAdminOverview (or as an interim improvement, perform three
targeted index-limited queries per status instead of collect()).

---

Outside diff comments:
In `@packages/backend/convex/events.ts`:
- Around line 89-138: Extract the duplicated enrichment logic in
getPublishedEvents and getPublicPublishedEventsPreview into a shared helper
enrichEventsWithTopicsAndSources(ctx, events): move the eventTopics preloading
(building topicsByEventId from eventIds and allEventTopicRows) and per-event
article/source/topic enrichment (loading articles, computing articleCount,
deduplicating sourceIds and loading sources, mapping topicIds from
topicsByEventId, filtering null sources) into that helper; update both functions
to call enrichEventsWithTopicsAndSources(ctx, events) and return the same shape
({ ...events, page: enrichedPage }) so future changes (e.g. projection) are
centralized.
- Around line 191-200: The beta check in getEventBySlug causes SSR callers to
get blocked; implement a public, SEO-safe slice (e.g., getEventBySlugPreview)
that does NOT call requireBetaAccess and returns only allowed fields (title,
perspectiveSummaries.center, imageUrl, imageAlt, canonical slug, and any minimal
SEO fields) for use by the route loader/serverHttpClient; keep the existing
getEventBySlug with requireBetaAccess for the authenticated detail UI. Also
audit and restrict getPublicPublishedEventsPreview so it no longer spreads
...event but explicitly selects only safe public columns to avoid leaking
sensitive fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e916df8b-616f-407b-a774-b7b9277dd83f

📥 Commits

Reviewing files that changed from the base of the PR and between 287c6de and 0685190.

⛔ Files ignored due to path filters (1)
  • packages/backend/convex/_generated/api.d.ts is excluded by !**/_generated/**, !**/_generated/**
📒 Files selected for processing (14)
  • apps/web/src/components/early-access-apply-card.tsx
  • apps/web/src/components/early-access-required.tsx
  • apps/web/src/components/sign-in-form.tsx
  • apps/web/src/components/sign-up-form.tsx
  • apps/web/src/routes/dashboard.tsx
  • apps/web/src/routes/event.$slug.tsx
  • apps/web/src/routes/feed.tsx
  • apps/web/src/routes/index.tsx
  • packages/backend/convex/auth.ts
  • packages/backend/convex/emails.ts
  • packages/backend/convex/events.ts
  • packages/backend/convex/lib/betaAccess.ts
  • packages/backend/convex/user.ts
  • packages/backend/convex/waitlist.ts

Comment thread apps/web/src/components/early-access-required.tsx Outdated
Comment thread apps/web/src/components/sign-up-form.tsx
Comment thread apps/web/src/routes/dashboard.tsx Outdated
Comment thread apps/web/src/routes/dashboard.tsx
Comment thread apps/web/src/routes/feed.tsx
Comment thread packages/backend/convex/emails.ts Outdated
Comment thread packages/backend/convex/lib/betaAccess.ts
Comment thread packages/backend/convex/lib/betaAccess.ts
Comment thread packages/backend/convex/waitlist.ts Outdated
Comment thread packages/backend/convex/waitlist.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 15

♻️ Duplicate comments (1)
packages/backend/convex/waitlist.ts (1)

122-167: 🧹 Nitpick | 🔵 Trivial

Stats still scan every waitlist row across all five statuses on every admin render.

getWaitlistStats and the stats block in getWaitlistAdminOverview both use .withIndex("by_status", ...).collect() for each of the 5 statuses just to read .length. That's index-bounded but still O(N) per status — i.e., the entire table is read on every invocation, and because these are reactive useQuerys, they re-run on every waitlist mutation.

For an interim improvement, replace .collect() with a Convex pagination/count primitive that doesn't materialize rows (or, longer-term, maintain a waitlistStats counters doc updated by addToWaitlist/inviteWaitlistUser/inviteNextPendingUsers/unsubscribe so this becomes O(1)). The nextPending / recentInvites .take(safeLimit) changes on Lines 244–253 already follow this pattern correctly.

As per coding guidelines for packages/backend/**: "Focus on Convex best practices" and "Review database schema changes carefully".

Also applies to: 215-254

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/convex/waitlist.ts` around lines 122 - 167, getWaitlistStats
is materializing all rows by calling .collect() for each status; replace those
five .collect() calls with a non-materializing count/pagination primitive (e.g.,
use the Convex query count API) so you get
pending/invited/converted/bounced/unsubscribed counts without fetching rows.
Update variable names (e.g., pendingCount, invitedCount) and the total
computation to sum the counts, and apply the same change to the analogous stats
block in getWaitlistAdminOverview (the stats section referenced in the comment)
so both use the count primitive instead of .collect(); keep the
safeLimit/.take(safeLimit) pattern used for nextPending/recentInvites as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/auth-social.tsx`:
- Around line 36-53: The inline SVG used for the Google logo in the auth-social
component is decorative and should be removed from the accessibility tree;
update the <svg className="mr-2 h-4 w-4"> element in the auth-social component
to include aria-hidden="true" and focusable="false" (so screen readers ignore
it) while keeping the visible styling and the existing button label "Continue
with Google".
- Around line 29-34: authClient.signIn.social(...) is returning a Promise that
is not handled; update the click handler for the social sign-in (the onClick
that calls authClient.signIn.social with provider "google" and callbackURL) to
handle the Promise outcome — either await it in an async handler or attach
.then/.catch, and surface failures using the same onError/toast pattern used in
sign-in-form.tsx (show a user-friendly toast on error and optionally log the
error), so network/OAuth failures no longer silently fail.

In `@apps/web/src/components/early-access-required.tsx`:
- Around line 38-42: The copy in the description string is redundant; update the
unauthenticated branch of the description constant (used with
access.authenticated, access.email, surfaceName) to replace "Sign in with the
invited email from your invite email" with a tightened phrase such as "Sign in
with the email address from your invite, or apply for access below." Keep the
surrounding sentence structure and interpolation unchanged so only the phrase is
swapped.
- Around line 7-12: AccessState.waitlistStatus is too permissive as string|null;
tighten it to the backend-known literal union (e.g. "pending" | "unsubscribed" |
"bounced" | null) or, better, derive the type from the Convex query result (e.g.
use FunctionReturnType<typeof api.user.getCurrentUserAccess> or the return type
of getCurrentUserBetaAccess) so the component's AccessState (referenced in
early-access-required.tsx) stays in sync with the backend; update the
AccessState type definition accordingly and import/alias the derived type where
AccessState is used.

In `@apps/web/src/components/sign-in-form.tsx`:
- Around line 32-34: The redirectTo prop is currently typed as string which
breaks TanStack Router's type-safe navigate({ to }) usage in
useNavigate/navigate; update the prop type for redirectTo in SignInForm (and
similarly in SignUpForm) to the union of valid routes (e.g., '/' | '/bookmarks'
| '/dashboard' | '/feed' | '/unsubscribe' | '/event/$slug' | '/api/auth/$') so
it matches the navigate({ to }) expected type, or add a runtime validation step
that ensures redirectTo is one of those route values and then cast (e.g.,
validatedPath as never) before calling navigate({ to: validatedPath }); ensure
references to useNavigate, navigate, and the redirectTo prop name are updated
accordingly.

In `@apps/web/src/components/sign-up-form.tsx`:
- Around line 54-59: Remove the manual onError type annotation in the
sign-up-form component and replace the current raw-error-to-UI behavior with the
same pattern used in sign-in-form: let TypeScript infer the better-auth callback
signature by omitting the explicit type on the onError handler, call
console.error(error) to log the raw error for diagnostics, and set/show a
generic user-facing message (e.g., "An unexpected error occurred. Please try
again.") instead of rendering error.error.message or error.error.statusText
directly in the UI.

In `@apps/web/src/routes/dashboard.tsx`:
- Around line 60-66: The effect that sets showSignIn based on invitePreview
currently runs on every invitePreview change and can override a user toggle;
modify the effect (the useEffect that reads invitePreview and calls
setShowSignIn) to stop re-applying the invite-driven default after the user has
manually toggled sign-in: add a userToggled ref (e.g., userToggledRef) that is
set to true inside the user action handler (onSwitchToSignIn / any handler that
calls setShowSignIn), and in the effect early-return if userToggledRef.current
is true; alternatively only apply the invitePreview logic once on mount by
checking a initialized ref so invitePreview no longer snaps the UI back after
user interaction.
- Around line 44-47: The redirect validation currently accepts any value where
search.redirect.startsWith("/"), which allows protocol-relative URLs like
"//evil.com"; update the check for redirectTo (and any use of search.redirect)
to explicitly reject values that begin with "//" by requiring a single leading
slash (e.g., ensure search.redirect is truthy and startsWith("/") AND does not
startWith("//") or use a regex like /^\/(?!\/)/) so only path-style redirects
are allowed.

In `@packages/backend/convex/emails.ts`:
- Around line 458-462: Update the stale HTML comment "<!-- Expiry note -->" in
the email template: either remove it or replace it with a comment that
accurately describes the current content (e.g. "Use same email reminder") so the
block containing the td with text "Use the same email address when you create
your account." is self-documenting; locate the comment immediately above that td
in the emails.ts template and change or delete it accordingly.

In `@packages/backend/convex/events.ts`:
- Around line 35-78: The current hot-feed path issues N×M reads because inside
the events.map loop you call ctx.db.get(sourceId) per-event per-source; instead,
collect all unique sourceIds across events (derive from articles for all
events), perform a single batch fetch via Promise.all(uniqueSourceIds.map((id)
=> ctx.db.get(id))) to build a lookup map, then in the events mapping (the async
events.map block) replace per-event Promise.all source fetches with a simple
lookup of sources by ID (filtering nulls) to attach sources and keep topic
lookup via topicsByEventId unchanged; update symbols: eventIds,
allEventTopicRows, topicsByEventId, articles, sourceIds, ctx.db.get, and the
final returned event object to read sources from the shared lookup.
- Around line 160-174: getPublicPublishedEventsPreview currently returns full
perspectiveSummaries (left/right/center) and globalImpact via
enrichEventsWithTopicsAndSources, while getEventBySlugPreview redacts to
perspectiveSummaries.center; make them consistent by redacting
getPublicPublishedEventsPreview to the same public shape: after calling
enrichEventsWithTopicsAndSources in getPublicPublishedEventsPreview, map each
event to replace perspectiveSummaries with an object containing only center
(perspectiveSummaries.center) and remove or null-out globalImpact (and any other
non-public fields), or reuse the same redaction helper used by
getEventBySlugPreview if one exists, ensuring both unauthenticated preview
endpoints expose the identical limited fields.

In `@packages/backend/convex/lib/betaAccess.ts`:
- Around line 27-33: isAdminEmail currently uses an unnecessary optional chain
and nullish fallback because getAdminEmails() always initializes
cachedAdminEmails; remove the dead-code by either asserting non-null after
population or adding a small accessor. Fix by updating isAdminEmail to call
getAdminEmails() as before and then use a non-null assertion:
cachedAdminEmails!.has(normalizeEmail(email)), or implement a private
getAdminEmailSet() that calls getAdminEmails(), returns a Set<string>, and use
getAdminEmailSet().has(normalizeEmail(email)); reference isAdminEmail,
getAdminEmails, cachedAdminEmails, and normalizeEmail when making the change.
- Around line 35-43: In getWaitlistRecordByEmail, replace the call to .unique()
on ctx.db.query("waitlist").withIndex("by_email", ...) with .first() so the
query returns the first matching document instead of throwing on duplicate
normalized emails; update the method to return the .first() result and ensure
callers (requireBetaAccess, getPublishedEvents, getEventBySlug, dashboard, feed)
continue to handle a nullable Doc<"waitlist"> as before, and plan a backfill to
deduplicate legacy mixed-case waitlist rows.

In `@packages/backend/convex/schema.ts`:
- Line 321: The schema index by_status_invitedAt is fine, but Convex lacks
native uniqueness for waitlist.email, so update the waitlist insert mutation to
enforce uniqueness at the application level: in the mutation (the function that
creates waitlist records — the counterpart to getWaitlistRecordByEmail in
betaAccess.ts) perform a transactional read-then-insert using Convex
transactions (read existing record by email via getWaitlistRecordByEmail inside
the same transaction and only insert if none exists), and add a one-time dedupe
migration to remove duplicate waitlist.email entries before deployment.

In `@packages/backend/convex/waitlist.ts`:
- Around line 179-205: getInvitePreview currently returns invitee PII
(email/name) for any caller; change it to avoid exposing PII to unauthenticated
callers by either enforcing authentication or redacting fields: in the
getInvitePreview handler, check the request context auth (e.g., ctx.auth /
ctx.user) and if unauthenticated return the same
isValid/status/position/timestamps but set email to null or a masked value and
name to null; alternatively, require an authenticated user and return a
401/unauthorized when ctx lacks credentials. Update references to the email/name
fields in callers (e.g., dashboard UI) to handle null/masked values accordingly.

---

Duplicate comments:
In `@packages/backend/convex/waitlist.ts`:
- Around line 122-167: getWaitlistStats is materializing all rows by calling
.collect() for each status; replace those five .collect() calls with a
non-materializing count/pagination primitive (e.g., use the Convex query count
API) so you get pending/invited/converted/bounced/unsubscribed counts without
fetching rows. Update variable names (e.g., pendingCount, invitedCount) and the
total computation to sum the counts, and apply the same change to the analogous
stats block in getWaitlistAdminOverview (the stats section referenced in the
comment) so both use the count primitive instead of .collect(); keep the
safeLimit/.take(safeLimit) pattern used for nextPending/recentInvites as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b0f02885-3cc0-48d6-aea1-c3743b167c94

📥 Commits

Reviewing files that changed from the base of the PR and between 0685190 and 7d56055.

📒 Files selected for processing (14)
  • apps/web/src/components/auth-social.tsx
  • apps/web/src/components/early-access-required.tsx
  • apps/web/src/components/sign-in-form.tsx
  • apps/web/src/components/sign-up-form.tsx
  • apps/web/src/routes/dashboard.tsx
  • apps/web/src/routes/event.$slug.tsx
  • apps/web/src/routes/feed.tsx
  • packages/backend/convex/clustering.ts
  • packages/backend/convex/config.ts
  • packages/backend/convex/emails.ts
  • packages/backend/convex/events.ts
  • packages/backend/convex/lib/betaAccess.ts
  • packages/backend/convex/schema.ts
  • packages/backend/convex/waitlist.ts

Comment thread apps/web/src/components/auth-social.tsx Outdated
Comment thread apps/web/src/components/auth-social.tsx Outdated
Comment thread apps/web/src/components/early-access-required.tsx
Comment thread apps/web/src/components/early-access-required.tsx Outdated
Comment thread apps/web/src/components/sign-in-form.tsx
Comment thread packages/backend/convex/events.ts
Comment thread packages/backend/convex/lib/betaAccess.ts
Comment thread packages/backend/convex/lib/betaAccess.ts
})
.index("by_email", ["email"])
.index("by_status", ["status", "createdAt"])
.index("by_status_invitedAt", ["status", "invitedAt"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

LGTM — index supports the new admin invite flows.

by_status_invitedAt is well-shaped for range queries like "invited users after timestamp T" or "most recently invited" used by the new admin/email plumbing. Be aware the index will trigger a backfill on first deploy, which is fine at current waitlist scale.

A small operational note: if you later need to enforce uniqueness on waitlist.email (relevant to the .unique() lookup in betaAccess.ts's getWaitlistRecordByEmail), Convex doesn't provide native unique constraints — you'll need an application-level check inside the insert mutation (read-then-insert under transactional semantics) plus a one-time dedupe migration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/convex/schema.ts` at line 321, The schema index
by_status_invitedAt is fine, but Convex lacks native uniqueness for
waitlist.email, so update the waitlist insert mutation to enforce uniqueness at
the application level: in the mutation (the function that creates waitlist
records — the counterpart to getWaitlistRecordByEmail in betaAccess.ts) perform
a transactional read-then-insert using Convex transactions (read existing record
by email via getWaitlistRecordByEmail inside the same transaction and only
insert if none exists), and add a one-time dedupe migration to remove duplicate
waitlist.email entries before deployment.

Comment thread packages/backend/convex/waitlist.ts
@flvvius
flvvius merged commit c2482ed into main Apr 26, 2026
3 of 4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request May 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant