Skip to content

feat(web): remind people about a meeting before it starts - #82

Merged
ralyodio merged 5 commits into
masterfrom
feat/meeting-reminders
Aug 19, 2026
Merged

feat(web): remind people about a meeting before it starts#82
ralyodio merged 5 commits into
masterfrom
feat/meeting-reminders

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Email and web push at 1 day, 1 hour, 15 minutes and 1 minute before a meeting. Four toggles in settings, each governing both channels, all on by default.

When a reminder is due

Each lead time owns a band rather than an instant — the day-before reminder covers 24h down to 1h, the hour-before covers 60 to 15 minutes, and so on.

The obvious implementation (fire when now is within a minute or two of start - lead) fails in both directions at once: miss the window to a deploy or a slow tick and the reminder is gone with nothing to show it, but widen the tolerance to compensate and the hour-before notice goes out twenty minutes late, when the fifteen-minute one is about to say something more accurate.

With bands there is no tolerance to tune. A runner down for three hours comes back and sends the tightest reminder still true, skipping the ones it slept through — the right fate for a reminder about something that has since drawn much closer. A test walks every minute of the day before a meeting and asserts exactly one lead answers at each, so there is no gap to drop a reminder and no overlap to send two.

The copy uses the real remaining time, not the band's name, so a meeting booked 25 minutes ahead is never told it starts "in 1 hour".

Sending each one exactly once

meeting_reminders is a ledger whose unique constraint is claimed before the message goes out, so overlapping cron runs cannot both send. At-most-once deliberately: a crash between claim and send loses one reminder, where the alternative can mail somebody repeatedly about a meeting already in their diary.

The key includes occurrence_at, and that column is the whole reason this works for recurring meetings. #81 made a recurring series one row whose scheduled_at is the next occurrence, rolled forward after each finishes. A key of (session, lead) would fire a weekly meeting's day-before notice once, in its first week, then stay silent for ever — with a ledger that looked correctly filled in. Keying on the instant reopens the slots at every roll-forward.

The toggles are not where the others are

The existing event toggles only render once the browser is subscribed to push, which is right for them — they describe push and nothing else. These four also govern the emailed reminder, so they get their own always-visible section. Hiding them behind a push subscription would leave anyone who has not enabled push, or cannot, receiving email they had no way to turn off.

Invitees have no account and therefore no settings row, so they get email with no per-lead control; declining the invitation stops all of it.

Scheduling

A minute cron (pg_cronpg_net) posts to /api/reminders/run with a shared secret. It lives in the app rather than in Postgres because the Resend client, the web-push library and the VAPID keys are already wired up here.

Without REMINDERS_CRON_SECRET the route refuses everything — an endpoint that mails a meeting's whole invitee list is not one to leave open because a variable was missed on a new environment. The secret is compared in constant time, with an explicit length check first so a wrong guess is a 401 rather than a 500 that leaks the length.

Deploying — three steps, and this PR is only the first

  1. Merge and deploy, so the route exists.
  2. Set REMINDERS_CRON_SECRET on the Railway web service.
  3. Enable pg_cron + pg_net and schedule the minute job.

The migration is already applied to production as 20260819103845_meeting_reminders — pairux has no CI that applies migrations, and supabase db push is not usable here: the repo has no supabase/config.toml, is not linked, and every repo filename differs from its recorded prod version (20260819120000_recurring_scheduled_sessions.sql is version 20260819100400 in prod), so a push would try to replay all 46 migrations.

Testing

18 new tests, all passing: 11 on the due-time logic, 7 on the endpoint's refusals. Typecheck clean across apps/web.

Note on the suite: 91 of 174 test files fail on master before this change — localStorage is not defined, no tests collection errors — because the root vitest config runs the node environment. Verified middleware.test.ts and settings/page.test.tsx fail identically on an untouched checkout. Not introduced here, but it means the suite cannot tell you whether this broke anything, which is worth fixing separately.

🤖 Generated with Claude Code

Email and web push at 1 day, 1 hour, 15 minutes and 1 minute before a meeting.
Four toggles in settings, each governing both channels, all on by default.

## When a reminder is due

Each lead time owns a *band* rather than an instant: the day-before reminder
covers 24 hours down to 1 hour, the hour-before covers 60 to 15 minutes, and so
on down. The obvious implementation -- fire when now is within a minute or two
of `start - lead` -- fails both ways at once. Miss the window to a deploy or a
slow tick and the reminder is gone with nothing to show it; widen the tolerance
to compensate and the hour-before notice goes out twenty minutes late, when the
fifteen-minute one is about to say something more accurate.

With bands there is no tolerance to tune. A runner that has been down for three
hours comes back and sends the tightest reminder still true, and skips the ones
it slept through -- which is the right fate for a reminder about something that
has since drawn much closer. The message says the real remaining time rather
than the band's name, so a meeting booked 25 minutes ahead is never told it
starts "in 1 hour".

## Sending each one once

`meeting_reminders` is a ledger whose unique constraint is claimed *before* the
message goes out, so overlapping cron runs cannot both send. At-most-once
deliberately: a crash between claim and send loses one reminder, where the
alternative can mail somebody repeatedly about a meeting already in their diary.

The key includes `occurrence_at`, and that column is the whole reason this works
for recurring meetings. #81 made a recurring series one row whose `scheduled_at`
is the next occurrence, rolled forward after each one finishes -- so a key of
(session, lead) would fire a weekly meeting's day-before notice once, in its
first week, and stay silent for ever with a ledger that looked correctly filled
in. Keying on the instant reopens the slots at every roll-forward.

## The toggles are not where the others are

The existing event toggles only render once the browser is subscribed to push,
which is right for them -- they describe push and nothing else. These four also
govern the emailed reminder, so they get their own always-visible section:
hiding them behind a push subscription would leave anyone who has not enabled
push, or cannot, receiving email they had no way to turn off.

Invitees have no account and therefore no settings row, so they get email with
no per-lead control; declining the invitation stops all of it.

## Scheduling

A minute cron (pg_cron -> pg_net) posts to /api/reminders/run with a shared
secret. It lives in the app rather than in Postgres because the Resend client,
the web-push library and the VAPID keys are already wired up here. Without
REMINDERS_CRON_SECRET the route refuses everything: an endpoint that mails a
meeting's whole invitee list is not one to leave open because a variable was
missed on a new environment.

Migration applied to prod as 20260819103845_meeting_reminders. The cron job is
not scheduled yet -- that waits for this to deploy and for the secret to be set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

66 finding(s)

HIGH/CRITICAL: 13 | MEDIUM: 35 | LOW: 18

Severity Rule Location
HIGH sh-eval-expansion .githooks/pre-commit:33
HIGH js-electron-node-integration apps/desktop/src/main/window.ts:49
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:691
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:715
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:813
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:815
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:894
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:910
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1064
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1066
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1128
HIGH sh-remote-script-execution apps/livekit/setup-livekit-server.sh:93
HIGH sh-remote-script-execution apps/turn/deploy-droplet.sh:62
MEDIUM insecure-temp-file .githooks/commit-msg:19
MEDIUM insecure-temp-file .githooks/post-commit:22
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:192
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:125
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:219
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:142
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM redos-nested-quantifier apps/web/src/lib/deliverable.ts:11
MEDIUM manifest-install-lifecycle-script package.json:55
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:313
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:341
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:264
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:288
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:201
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:261
MEDIUM js-shell-exec-interpolation scripts/release.mjs:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:146
MEDIUM js-shell-exec-interpolation scripts/release.mjs:147
MEDIUM js-shell-exec-interpolation scripts/version-bump.mjs:169
MEDIUM js-shell-exec-interpolation scripts/version-bump.mjs:170
MEDIUM js-shell-exec-interpolation scripts/version-bump.mjs:171
LOW insecure-temp-file apps/desktop/src/main/streaming/index.test.ts:64
LOW secret-generic-credential apps/livekit/fly.toml:12

…and 16 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

ralyodio and others added 4 commits August 19, 2026 10:51
91 of 174 test files failed on master, 282 tests with them, on `document is not
defined`, `localStorage is not defined` and `Cannot find package '@/lib/...'`.
None of it was real.

The root vitest.config.ts declared `environment: 'node'` and its own
repository-wide `include`, which silently overrode the vitest.config.ts that
apps/web, apps/mobile, apps/desktop, packages/shared-types and packages/ai-core
each already had. apps/web's has specified jsdom, the `@/` alias and a setup
file all along: the sixteen settings tests that fail from the root pass through
it without touching a line of test code.

`projects` lets each workspace keep its environment, its aliases and its setup.
That is not a tidiness argument -- this repo has three different `@/` aliases
(apps/web/src, apps/mobile/src, apps/desktop/src/renderer), so no single
root-level alias could ever have served them.

    before   91 failed | 83 passed  (174 files),  282 failed |  855 passed
    after             174 passed    (174 files),           1780 passed

The test count rises by 643 because most of those files were failing during
collection, so their tests had never run at all and were never counted.

Two workspaces needed a config of their own first, and both were places where
this change could have quietly reduced coverage instead of restoring it:

  - packages/remote-input has 12 test files and no config. It needs jsdom
    rather than node, because it maps browser key and pointer events.
  - scripts/ has 3 test files and is not a workspace, so a projects list of
    apps/* and packages/* would have dropped them -- and reported 171 files,
    all passing, which is exactly the shape of a problem nobody notices.

That is why the list is written out rather than globbed, and why the check was
the file count and not the pass count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/vitest.config.ts already existed, for running vitest from inside
scripts/. The previous commit replaced it wholesale and dropped its explicit
root and its 10s testTimeout along the way.

Both are back. The only difference from the original is now the comment
explaining why the root config has to name this directory: it is not a pnpm
workspace, so a projects list of apps/* and packages/* skips it entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's Lint job failed on nine errors in the new files. This repo's eslint config
is stricter than the typecheck: it rejects `as` casts that widen, rejects `!`
assertions outright, and rejects reading fields off PostgREST's `any` rows
straight into a template or an object literal.

  - `dueLead` now iterates `LEAD_MINUTES.entries()`, which hands back the element
    already typed. Indexing needed either a cast or a `!` to convince the
    compiler the element exists, and both are forbidden here.
  - `admin()` returns an inferred type. Annotating it `SupabaseClient` was the
    unsafe-return error: the bare type defaults its schema parameters
    differently from what `createClient` actually returns.
  - The host's profile row is narrowed once, into a named `ProfileRow`, rather
    than at each use.
  - Dropped a redundant `Number()`, an `?? []` on a value already narrowed
    non-null, and one level of optional chaining that could not be nullish.

No behaviour changes. Typecheck clean, 174 files and 1780 tests still passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I linted the new sources and not the new tests, so a number in a template
literal survived into CI. Same rule, same fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit e4d763b into master Aug 19, 2026
10 of 11 checks passed
@ralyodio
ralyodio deleted the feat/meeting-reminders branch August 19, 2026 11:06
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