Release the customer portal
Use this gate for any production change that depends on the customer collaboration schema, portal Edge Function, Trigger schedules, or customer email. Keep the email gate disabled until one controlled recipient completes the acceptance flow.
Confirm the production target
Verify these targets before any release check:
| Surface | Production target |
|---|---|
| Vercel app | cprinos-7741s-projects/sable |
| Vercel root | web |
| App URL | https://sable.indigotrigger.ai |
| Supabase project | fvngkfrmienkltuwtoxa |
| Collaboration migration | 20260911000449_customer_portal_collaboration.sql |
| Resource activation migration | 20260911024049_portal_resource_activation.sql |
| Unified release migration | 20260911024549_unify_deliverable_release_approval.sql |
Production releases follow the Git path in Deployment. Do not run a direct Vercel production deploy, Supabase function deploy, Trigger deploy, or workstation db push.
Run the pre-merge checks
Run the implementation suites from the repository root:
node --test supabase/tests/*.test.mjs web/tests/*.test.mjs
cd <sable-checkout> && npm test
npx tsc --noEmit --moduleResolution bundler --module esnext --target es2022 --types node --skipLibCheck trigger.config.ts trigger/*.ts trigger/lib/*.ts
cd docs-site && npm run build
Confirm each migration executes against an isolated production-equivalent database before approving the release. The portal migrations are additive, emit no customer notification backfill, and give existing members in-app notifications with email disabled.
Apply schema before dependent workers
The collaboration route, Edge Function, and Trigger worker require the migration’s tables and remote procedure calls (RPCs). Complete these checks in order:
- Apply the reviewed migration through
.github/workflows/sable-migrate.yml. - Verify the remote migration ledger contains
20260911000449,20260911024049, and20260911024549when the release includes resource activation and unified approval release. - Merge the checked application release.
- Wait for
.github/workflows/sable-deploy.ymlto deploy Edge Functions and the Trigger worker. - Verify Vercel generated production from the merged
maincommit.
If schema and application changes share one release, keep SABLE_PORTAL_EMAIL_DELIVERY_ENABLED false until the migration and both worker deployments succeed. Portal API routes fail closed when their required tables or configuration are unavailable.
Client Portal Essentials deploy order
The Client Portal Essentials release (customer tabs, This week, important links, auto-share, weekly drafts) adds portal_policies, portal_links, the auto_release_client_meetings function and an inbox-email backfill. Deploy in this order:
- Migrations, through
sable-migrate.yml. Run the backfill (20260923180000_backfill_inbox_email_channel.sql) in a low-traffic window: it updates existing emailsourcesrows in place. - Edge functions, so
portal-notificationsknows about the tab guard and inbound mail records forwarded senders. - Vercel, from the merged
maincommit. - Trigger, so the four new tasks register.
The new reads tolerate missing tables: until the migration lands, every client reads as the default five tabs with no links, and the Trigger tasks log once and do nothing. Saving tab or automation settings still fails until the tables exist.
Configure Auth and canonical links
Configure access email separately from activity email:
- Set the Supabase Auth Site URL to the production SABLE origin.
- Add
/auth-callback.htmlURLs for production and approved acceptance-test previews to the redirect allowlist. - Configure and verify Supabase Auth SMTP for invitations and password recovery.
- Set
SABLE_WEB_URL=https://sable.indigotrigger.aiin the Vercel and Supabase Edge environments.
Invitation and recovery links must return to the exact authorized client, view, and id destination. The callback rejects cross-origin and protocol-relative redirect targets.
Configure the notification transport
Set these Supabase Edge secrets by name:
RESEND_API_KEYRESEND_FROMCRON_SECRETSABLE_WEB_URLSABLE_PORTAL_EMAIL_DELIVERY_ENABLED
The Trigger environment needs SUPABASE_URL, SUPABASE_ANON_KEY, and the same CRON_SECRET. The worker passes the anon key to the Supabase gateway and x-cron-secret to the Edge Function’s scheduled-caller gate.
Start with SABLE_PORTAL_EMAIL_DELIVERY_ENABLED=false. The in-app outbox and inbox continue to operate while activity email stays gated.
Verify delivery readiness without sending email
Call the health mode through scheduled-caller authentication:
curl -sS "$SUPABASE_URL/functions/v1/portal-notifications" \
-H "Authorization: Bearer $SUPABASE_ANON_KEY" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "x-cron-secret: $CRON_SECRET" \
-H "content-type: application/json" \
--data '{"mode":"health"}'
Health mode does not claim work or contact a recipient. It reports whether the gate is enabled, required Resend secrets exist, the provider responds, and the sender domain can send. readiness.ready becomes true only when the email gate and verified sender domain are both ready.
Verify every Trigger schedule
Confirm the deployed Trigger worker registered these tasks:
| Task | Schedule |
|---|---|
sable.portal.notifications.dispatch | Every 2 minutes |
sable.portal.notifications.digest.daily | 08:15 America/New_York daily |
sable.portal.notifications.digest.weekly | 08:30 America/New_York every Monday |
sable.portal.notifications.digest.retry | Every 15 minutes |
sable.portal.notifications.reminders | 09:05 America/New_York on weekdays |
sable.portal.attachments.cleanup | 03:35 America/New_York daily |
sable.portal.notifications.health | Not scheduled; run by hand |
sable.weekly-update.draft | Friday 12:00 America/New_York |
sable.weekly-update.draft.project | Child run, one per project, started by the task above |
sable.portal.auto-release | Hourly |
sable.portal.auto-release.client | On demand with { clientId }, started when a lead turns automatic sharing on |
The four automation tasks never approve or publish a draft, run on their own queues, and do not call the Edge function. The six notification tasks share the sable-portal-notifications queue with concurrency one, as does the on-demand health task. Each of those retries the Edge invocation up to five times. Email delivery rows have their own retry and lease state inside Supabase.
Before flipping a delivery gate, run sable.portal.notifications.health. It returns the Edge function's readiness object — gate_enabled, whether the Resend key and sender are configured, provider_reachable and sender_domain_verified — and sends nothing: it claims no outbox rows and contacts no recipient. Run it again afterwards; gate_enabled should be the only field that changed.
Run controlled acceptance
Use synthetic records and one approved recipient. Do not create customer test data or send unsolicited customer messages.
Verify these boundaries:
- Anonymous requests receive 401 from every portal API
- A Client A member cannot read or mutate Client B records
- A revoked member receives 403 with the existing Auth token
- Invitation acceptance and recovery return to the exact safe portal destination
- A
client_viewercan comment and reply but cannot approve - A
client_reviewercan decide the current released version - A consultant sees only active or current assigned projects
- Resources and Ask SABLE contain released evidence only
- Publication inventory totals match available, shared, and release-ready records
- A consultant can browse only assigned active or current projects and cannot publish
- An admin or lead can publish and withdraw curated resource metadata with revision protection
- Direct customer update publication creates one update and one idempotent receipt
- Draft deep links reject a draft outside the authorized project instead of opening another record
- Direct Workbench release rejects missing source drafts, non-ready quality gates, QA issues, and missing inputs
- Exact release replay and unchanged-current-content replay create no duplicate version or email
- A legacy
deliverable_releaseapproval either creates the same immutable release or remains pending on a readiness 409 - Batch approval refuses
deliverable_releaserows and reports that individual release is required - Internal notes stay out of customer payloads, search, email, and assistant evidence
- A stale revision returns 409 and retrying one idempotency key does not duplicate work
- A real supported file completes signed upload, publishes with a reply, and downloads through a 60-second signed URL
- Request assignment, next-response ownership, resolution receipt, milestone changes, and review decisions persist after reload
- Customer tabs & automation: turning a tab off hides it from the customer (staff still see it, marked hidden), the landing tab must be one that is on, and a stale save returns 409
- This week shows the latest update, recent shared meetings and, when set, important links and key documents; it appears only when the lead enables it
- Important links can be added, reordered and archived by a lead, reject non-http(s) addresses, and a project-scoped link is hidden from members outside that project
- Automatic meeting sharing releases only client-domain meetings with a title and summary; the recording link stays unshared unless its separate setting is on (it is off by default), and the transcript is never shared
- With weekly drafts on, the Friday run creates a draft in Drafts that stays
pending_approval; nothing reaches the customer until a person approves it - Approving a publishing item that is no longer eligible (Internal source, or a weekly wrap whose listed meeting has passed or changed) stays pending and returns 409 with a Regenerate message
Keep activity email disabled during the first pass. Confirm in-app notification materialization, unread state, and preferences before testing transport.
Activate a customer workspace
Activation uses real customer-facing records, never seeded production activity:
- Open the customer’s staff preparation view.
- Review exact counts for memberships, shared resources, published updates, deliverables, milestones, and requests.
- Browse document, meeting, and draft candidates within active or current projects.
- Publish approved resource metadata with a customer-facing title and summary.
- Publish a direct update only when a named staff member approves that customer communication.
- Release approved deliverables from Draft Workbench.
- Publish the shared plan milestone and commitments.
- Preview each customer tab the client has enabled, including This week if it is on (staff see every other disabled tab marked hidden, except This week, which staff also see only when it is on), and run a released-evidence Ask SABLE question.
- Invite the named customer and record the factual delivery state.
The publication preview must remain metadata-only. Do not copy a document body or meeting transcript into a customer summary. Withdrawing an explicit share requires the current revision and a reason; the document remains visible when an active immutable released deliverable still links it.
Enable activity email with one recipient
After health and in-app checks pass:
- Set
SABLE_PORTAL_EMAIL_DELIVERY_ENABLED=truein the Supabase Edge environment. - Repeat health mode and require
readiness.ready: true. - Enable activity email for the controlled recipient.
- Create one explicit request or review event.
- Confirm the inbox row, delivery row, Resend provider ID, and exact portal link.
- Test one daily or weekly digest in the recipient’s saved time zone.
- Revoke or disable the synthetic recipient after acceptance.
Do not infer transport success from an outbox row. A successful path has a materialized inbox row and a portal_notification_deliveries.state of delivered. pending, processing, retry, suppressed, and failed are distinct operational outcomes.
Verify lease recovery and cleanup
The worker uses 180-second delivery leases and stable provider idempotency keys. Verify an expired processing lease can be reclaimed and that a persisted digest batch completes or defers as one group.
Attachment cleanup processes unpublished drafts after 24 hours. Confirm the cleanup task moves a claimed row to deleting, removes its private Storage object, and records deleted. A failed deletion must become cleanup_failed with a future retry, and an expired deleting lease must remain reclaimable.