From 112c64a22b75bd592768d908614e41247524519c Mon Sep 17 00:00:00 2001 From: senke Date: Thu, 30 Apr 2026 22:38:12 +0200 Subject: [PATCH] feat(soft-launch): cohort tooling + email template + monitor + checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The soft-launch report doc (SOFT_LAUNCH_BETA_2026.md) had the narrative — cohort table, email body inline, monitoring list, acceptance gate. But the operational pieces were notes-to-self : "add migration if missing", "Typeform to-do", "schema TBD". The operator was supposed to assemble them on the day, which on a soft- launch day is the worst possible time. Added the missing 6 pieces so the day-of work is "tick boxes", not "build the tooling" : * migrations/990_beta_invites.sql — schema with code (16-char base32-ish), email, cohort label, used_at, expires_at + 30d default, sent_by FK with ON DELETE SET NULL. Three indexes : unique on code (signup-path lookup), cohort (post-launch attribution report), partial expires_at WHERE used_at IS NULL (cleanup cron). * scripts/soft-launch/validate-cohort.sh — sanity check on the operator's CSV : header form, malformed emails, duplicates, cohort distribution (≥50 total / ≥5 creators / ≥3 distinct labels), optional collision check against existing users. Exit codes 0 / 1 (block) / 2 (warn-but-proceed). Hard checks block, soft checks let the operator override with FORCE=1. * scripts/soft-launch/send-invitations.sh — split-phase : step 1 (default) inserts beta_invites rows + renders one .eml per recipient under scripts/soft-launch/out-/ step 2 (SEND=1) dispatches via $SEND_CMD (msmtp by default) so the operator can review the rendered emls before sending 100 emails. Per-recipient transactional INSERT so a partial failure doesn't poison the table. Failed inserts logged with the offending email so the operator can rerun on the subset. * templates/email/beta_invite.eml.template — proper MIME multipart (text + HTML) eml ready for sendmail-compatible piping. French copy aligned with the éthique brand (no FOMO, no urgency manipulation, no "limited spots" framing). * scripts/soft-launch/monitor-checks.sh — polls the 6 acceptance- gate signals defined in SOFT_LAUNCH_BETA_2026.md §"Acceptance gate" : testers signed up, Sentry P1 events, status page, synthetic parcours, k6 nightly age, HIGH issues. Each gate independently emits ✅ / 🔴 / ⚪ (last for "couldn't check"). Verdict on stdout. LOOP=1 keeps polling every CHECK_INTERVAL seconds. Designed for cron + tmux, not for an interactive UI. * docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md — pre-flight gate that must reach 100% green before the first invitation goes out. T-72h section (database, cohort, email infra, redemption path, monitoring, comms), D-day section (last-hour, send, hour-1, every-4h), 18:00 UTC decision call section. Linked back to the bigger SOFT_LAUNCH_BETA_2026.md so the operator can navigate between the "what" (report) and the "how / has-everything- been-checked" (this checklist) without losing context. What still requires the operator on the day : - Build the cohort CSV (curate emails from real sources) - Create the Typeform feedback form ; paste its URL into the eml template once known - Configure msmtp / sendmail ($SEND_CMD) - Press the send button - Show up at 18:00 UTC for the decision call Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md | 150 +++++++++++ scripts/soft-launch/monitor-checks.sh | 255 ++++++++++++++++++ scripts/soft-launch/send-invitations.sh | 179 ++++++++++++ scripts/soft-launch/validate-cohort.sh | 173 ++++++++++++ .../migrations/990_beta_invites.sql | 65 +++++ .../templates/email/beta_invite.eml.template | 92 +++++++ 6 files changed, 914 insertions(+) create mode 100644 docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md create mode 100755 scripts/soft-launch/monitor-checks.sh create mode 100755 scripts/soft-launch/send-invitations.sh create mode 100755 scripts/soft-launch/validate-cohort.sh create mode 100644 veza-backend-api/migrations/990_beta_invites.sql create mode 100644 veza-backend-api/templates/email/beta_invite.eml.template diff --git a/docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md b/docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md new file mode 100644 index 000000000..0af046f19 --- /dev/null +++ b/docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md @@ -0,0 +1,150 @@ +# Soft-launch beta — pre-flight checklist + +> Operational checklist that must reach 100% green before the first +> invitation goes out. Companion to `docs/SOFT_LAUNCH_BETA_2026.md` +> (the bigger picture). This file is purely the "before you press +> send, has every gate been verified?" view. + +The whole reason the soft-launch is "soft" is that it lets you catch +infrastructure surprises with 50 testers instead of 50 000. To get +that benefit, the infrastructure has to actually work BEFORE the +invitations land. This checklist is the gate. + +## T-72h checklist (3 days before send) + +### Database + +- [ ] `migrations/990_beta_invites.sql` applied to staging. + Verify with : + ```bash + psql "$STAGING_DATABASE_URL" -c "SELECT count(*) FROM beta_invites;" + ``` + Expected : `0` (table exists, empty). +- [ ] Same migration applied to prod (whenever prod tag goes out). +- [ ] Backup-freshness OK on both environments : + ```bash + pgbackrest --stanza=veza info | head -20 + ``` + Most recent full or diff < 24 h old. + +### Cohort CSV + +- [ ] CSV file built from the operator's chosen sources (mailing list + + contacts + community partners). Format per + `scripts/soft-launch/validate-cohort.sh` header. +- [ ] `validate-cohort.sh` returns exit 0 (or exit 2 with explicit + operator acknowledgement of the warnings). +- [ ] Distribution sanity : `≥ 5` creators, `≥ 20` listeners, `≥ 3` + distinct cohort labels, `≥ 50` total rows. + +### Email infrastructure + +- [ ] SMTP credentials live in the operator's machine `~/.msmtprc` + (or whatever `SEND_CMD` resolves to). +- [ ] `templates/email/beta_invite.eml.template` reviewed — wording, + cohort variable, code variable. +- [ ] Test send to operator's own email : + ```bash + echo "ops@veza.fr,test-cohort,ops@veza.fr" > /tmp/me.csv + DATABASE_URL=$STAGING_DATABASE_URL FRONTEND_URL=https://staging.veza.fr \ + SEND=1 bash scripts/soft-launch/send-invitations.sh /tmp/me.csv + ``` + Verify the eml renders correctly in your mail client (links + clickable, fonts loaded, no `{{TO_ADDR}}` literals leaking). + +### Backend invite-redemption path + +- [ ] Visit `https://staging.veza.fr/signup?invite=`. + Expected : signup form pre-fills the code, refuses to submit + without it, marks the invite as `used_at = NOW()` after success. +- [ ] Try an invalid code → form rejects with a clear error message. +- [ ] Try the same code twice → second attempt rejects (one-time use). +- [ ] Try an expired code → form rejects with "expired". + +### Acceptance-gate monitoring + +- [ ] Run `monitor-checks.sh` once on staging — every gate either ✅ + or ⚪ (unknown), no 🔴. + ```bash + DATABASE_URL=$STAGING_DATABASE_URL \ + SENTRY_AUTH_TOKEN=... \ + PROM_URL=https://prom.veza.fr \ + bash scripts/soft-launch/monitor-checks.sh + ``` +- [ ] Schedule the cron run (or tmux session) so the gate state is + visible during the bêta window without manual re-run. + +### Communications + +- [ ] Discord `#beta-feedback` channel created, ground rules pinned. +- [ ] Typeform feedback form created ; URL pasted into + `templates/email/beta_invite.eml.template` if not already in the + cohort label. +- [ ] Status page maintenance window declared for the duration — + "elevated alerting may occur during beta period." +- [ ] Operators on duty for the day rota'd in the calendar (every 4 h + shift, primary + backup). + +## D-day checklist (the day of send) + +### Last hour before send + +- [ ] Most recent k6 nightly green (within 30 h). +- [ ] No pending high-severity Sentry issue. +- [ ] No PagerDuty incident open. +- [ ] HAProxy + backend healthchecks green : + ```bash + curl -s https://staging.veza.fr/api/v1/health | jq .status + ``` +- [ ] MinIO drives all online ; pgBackRest drill ran successfully in + the last 7 days. + +### Send + +- [ ] `validate-cohort.sh` exit code 0 (or 2 with explicit override). +- [ ] `send-invitations.sh` in DRY-RUN mode : eml output dir reviewed. +- [ ] `send-invitations.sh` with `SEND=1` : dispatch.log reviewed + after run, `0` failed dispatches. +- [ ] First three invitees received the email within 5 min (manual + check on three different domains : gmail / proton / one custom). + +### Hour 1 post-send + +- [ ] First sign-up landed (`SELECT count(*) FROM beta_invites WHERE + used_at IS NOT NULL;` returns ≥ 1). +- [ ] No spike in 5xx on Grafana "Veza API Overview". +- [ ] Discord `#beta-feedback` has at least one "I'm in" message. + +### Every 4 h during the bêta window + +- [ ] Re-run `monitor-checks.sh` (or the cron wakes you). +- [ ] Triage any HIGH-severity report within 1 h (per + `docs/SOFT_LAUNCH_BETA_2026.md` §"Issue triage matrix"). +- [ ] Update the issues-reported table in + `docs/SOFT_LAUNCH_BETA_2026.md` so the decision call has fresh data. + +## D+0 18:00 UTC — decision call + +- [ ] Tech lead, product lead, on-call engineer all on the call. +- [ ] `monitor-checks.sh` final run shown live ; verdict screenshotted. +- [ ] Each acceptance-gate row from `SOFT_LAUNCH_BETA_2026.md` + §"Acceptance gate" walked through verbally. +- [ ] Unanimous GO or any one NO-GO documented in the meeting notes. +- [ ] Decision logged in `docs/SOFT_LAUNCH_BETA_2026.md` §"Take-aways". + +If GO : the v2.0.0-public tag goes out the next morning. +If NO-GO : the meeting decides scope of fix-pass + new acceptance date. + +## Linked artefacts + +- `docs/SOFT_LAUNCH_BETA_2026.md` — the bigger picture (cohort + definition, email template inline, day timeline, monitoring list, + acceptance gate, decision protocol) +- `migrations/990_beta_invites.sql` — schema this depends on +- `scripts/soft-launch/validate-cohort.sh` — pre-send sanity check +- `scripts/soft-launch/send-invitations.sh` — batch insert + send +- `scripts/soft-launch/monitor-checks.sh` — live gate poll +- `templates/email/beta_invite.eml.template` — the email recipients + receive +- `docs/GO_NO_GO_CHECKLIST_v2.0.0_PUBLIC.md` — the v2.0.0 checklist + this unblocks diff --git a/scripts/soft-launch/monitor-checks.sh b/scripts/soft-launch/monitor-checks.sh new file mode 100755 index 000000000..00fc057a0 --- /dev/null +++ b/scripts/soft-launch/monitor-checks.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# monitor-checks.sh — poll the soft-launch acceptance gate live during +# the bêta window so the operator gets a heads-up before the decision +# call instead of discovering at 18:00 UTC that one threshold is red. +# +# Acceptance gate (per docs/SOFT_LAUNCH_BETA_2026.md §"Acceptance gate") : +# - ≥ 50 testers signed up (used_at != NULL on beta_invites) +# - 0 P1 events in Sentry today +# - Status page green for the last 4 h +# - Synthetic parcours all green for 6 h +# - Nightly k6 load test green +# - < 3 HIGH-severity issues reported +# +# v1.0.10 Cluster 3.4. +# +# Usage : +# DATABASE_URL=postgres://... \ +# SENTRY_AUTH_TOKEN=... \ +# STATUSPAGE_URL=https://status.veza.fr \ +# PROM_URL=https://prom.veza.fr \ +# bash scripts/soft-launch/monitor-checks.sh +# +# By default the script runs once and exits with the gate's verdict. +# Run it from cron (e.g. every 30 min) or pass LOOP=1 to keep checking +# in-place every CHECK_INTERVAL seconds (default 600 = 10 min). +# +# Optional env : +# LOOP=1 continuous mode +# CHECK_INTERVAL seconds between checks in LOOP mode (default 600) +# QUIET=1 only emit the verdict line (for cron piping) +# THRESHOLD_TESTERS override 50 (default), e.g. set to 100 for +# a stricter sub-window +# +# Exit codes : +# 0 — every gate green +# 1 — at least one gate red +# 2 — at least one gate could not be checked (collector down, +# token wrong, etc.) — operator must verify manually +# 3 — required env / tool missing +set -euo pipefail + +DATABASE_URL=${DATABASE_URL:-?} +SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN:-?} +STATUSPAGE_URL=${STATUSPAGE_URL:-https://status.veza.fr} +PROM_URL=${PROM_URL:-?} +LOOP=${LOOP:-0} +CHECK_INTERVAL=${CHECK_INTERVAL:-600} +QUIET=${QUIET:-0} +THRESHOLD_TESTERS=${THRESHOLD_TESTERS:-50} + +[ "$DATABASE_URL" = "?" ] && { echo "DATABASE_URL required" >&2; exit 3; } +[ "$SENTRY_AUTH_TOKEN" = "?" ] && { echo "SENTRY_AUTH_TOKEN required (read scope sufficient)" >&2; exit 3; } +[ "$PROM_URL" = "?" ] && { echo "PROM_URL required" >&2; exit 3; } + +command -v psql >/dev/null 2>&1 || { echo "psql required" >&2; exit 3; } +command -v curl >/dev/null 2>&1 || { echo "curl required" >&2; exit 3; } +command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 3; } + +# ---------------------------------------------------------------------- +# Individual gate checks. Each prints "✅ " / "🔴 " / "⚪ " +# (last for "could not check"), and sets one of GATE_*_OK to 0 / 1 / 2. +# ---------------------------------------------------------------------- + +GATE_TESTERS_OK=2 +GATE_SENTRY_OK=2 +GATE_STATUSPAGE_OK=2 +GATE_SYNTHETIC_OK=2 +GATE_K6_OK=2 +GATE_ISSUES_OK=2 + +check_testers() { + local count + count=$(psql "$DATABASE_URL" -A -t -c " + SELECT count(*) FROM beta_invites WHERE used_at IS NOT NULL; + " 2>/dev/null | tr -d ' ' || echo "?") + if [ "$count" = "?" ] || ! [[ "$count" =~ ^[0-9]+$ ]]; then + echo "⚪ testers signed-up : check failed (psql)" + GATE_TESTERS_OK=2 + return + fi + if [ "$count" -ge "$THRESHOLD_TESTERS" ]; then + echo "✅ testers signed-up : $count / $THRESHOLD_TESTERS" + GATE_TESTERS_OK=0 + else + echo "🔴 testers signed-up : $count / $THRESHOLD_TESTERS" + GATE_TESTERS_OK=1 + fi +} + +check_sentry_p1() { + # Sentry API : count of unresolved P1 issues last 24h. + local count + count=$(curl -s -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \ + "https://sentry.io/api/0/projects/veza/veza-backend/issues/?statsPeriod=24h&query=is:unresolved%20level:fatal" \ + 2>/dev/null | jq 'length' 2>/dev/null || echo "?") + if [ "$count" = "?" ] || ! [[ "$count" =~ ^[0-9]+$ ]]; then + echo "⚪ Sentry P1 events 24h : check failed (auth or network)" + GATE_SENTRY_OK=2 + return + fi + if [ "$count" -eq 0 ]; then + echo "✅ Sentry P1 events 24h : 0" + GATE_SENTRY_OK=0 + else + echo "🔴 Sentry P1 events 24h : $count (must be 0)" + GATE_SENTRY_OK=1 + fi +} + +check_statuspage() { + local status + status=$(curl -s "$STATUSPAGE_URL/api/v1/status" 2>/dev/null \ + | jq -r '.indicator // .status.indicator // ""' 2>/dev/null || echo "") + case "$status" in + none|operational) + echo "✅ status page : $status (green)" + GATE_STATUSPAGE_OK=0 + ;; + minor|major|critical) + echo "🔴 status page : $status" + GATE_STATUSPAGE_OK=1 + ;; + *) + echo "⚪ status page : check failed (got '$status')" + GATE_STATUSPAGE_OK=2 + ;; + esac +} + +check_synthetic() { + # PromQL : sum of probe_success over the last 6h ; expect every + # parcours at 1 (success). + local query='probe_success{probe_kind="synthetic"} == 0' + local resp + resp=$(curl -s --get "$PROM_URL/api/v1/query" \ + --data-urlencode "query=$query" 2>/dev/null) + local result_count + result_count=$(echo "$resp" | jq '.data.result | length' 2>/dev/null || echo "?") + if [ "$result_count" = "?" ] || ! [[ "$result_count" =~ ^[0-9]+$ ]]; then + echo "⚪ synthetic parcours : check failed (Prometheus)" + GATE_SYNTHETIC_OK=2 + return + fi + if [ "$result_count" -eq 0 ]; then + echo "✅ synthetic parcours : all green" + GATE_SYNTHETIC_OK=0 + else + local failing + failing=$(echo "$resp" | jq -r '.data.result[].metric.parcours' 2>/dev/null | tr '\n' ',' | sed 's/,$//') + echo "🔴 synthetic parcours : $result_count failing ($failing)" + GATE_SYNTHETIC_OK=1 + fi +} + +check_k6_nightly() { + # k6 nightly is exposed as veza_k6_nightly_last_success_timestamp_seconds + # by the Forgejo runner workflow's textfile-collector. Reading via Prom + # gives "is the last success < 30h old?". + local query='time() - veza_k6_nightly_last_success_timestamp_seconds' + local resp age + resp=$(curl -s --get "$PROM_URL/api/v1/query" \ + --data-urlencode "query=$query" 2>/dev/null) + age=$(echo "$resp" | jq -r '.data.result[0].value[1] // ""' 2>/dev/null) + if [ -z "$age" ] || [ "$age" = "null" ]; then + echo "⚪ k6 nightly : check failed (metric absent — runner offline?)" + GATE_K6_OK=2 + return + fi + age_int=$(printf '%.0f' "$age" 2>/dev/null || echo 999999) + if [ "$age_int" -lt 108000 ]; then # 30h + echo "✅ k6 nightly : last success $(( age_int / 3600 ))h ago" + GATE_K6_OK=0 + else + echo "🔴 k6 nightly : last success $(( age_int / 3600 ))h ago (> 30h)" + GATE_K6_OK=1 + fi +} + +check_high_issues() { + # The operator-reported issues count lives in the SOFT_LAUNCH_BETA_2026.md + # report under "Issues reported". Without an external tracker we read it + # from a known location in the report file. Skip if file absent. + local report + report="$(cd "$(dirname "$0")/../.." && pwd)/docs/SOFT_LAUNCH_BETA_2026.md" + if [ ! -f "$report" ]; then + echo "⚪ HIGH issues count : report file not found" + GATE_ISSUES_OK=2 + return + fi + local count + count=$(grep -cE '^\| HIGH ' "$report" 2>/dev/null || echo 0) + if [ "$count" -lt 3 ]; then + echo "✅ HIGH-severity issues reported : $count / < 3" + GATE_ISSUES_OK=0 + else + echo "🔴 HIGH-severity issues reported : $count / < 3" + GATE_ISSUES_OK=1 + fi +} + +# ---------------------------------------------------------------------- +# Main loop +# ---------------------------------------------------------------------- + +run_once() { + if [ "$QUIET" != "1" ]; then + echo "================================================================" + echo "Acceptance gate check — $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + echo "----------------------------------------------------------------" + fi + + check_testers + check_sentry_p1 + check_statuspage + check_synthetic + check_k6_nightly + check_high_issues + + if [ "$QUIET" != "1" ]; then + echo "----------------------------------------------------------------" + fi + + local red=0 unknown=0 + for v in "$GATE_TESTERS_OK" "$GATE_SENTRY_OK" "$GATE_STATUSPAGE_OK" \ + "$GATE_SYNTHETIC_OK" "$GATE_K6_OK" "$GATE_ISSUES_OK"; do + case $v in + 1) red=$(( red + 1 )) ;; + 2) unknown=$(( unknown + 1 )) ;; + esac + done + + if [ "$red" -eq 0 ] && [ "$unknown" -eq 0 ]; then + echo "VERDICT : ALL GATES GREEN — soft-launch is GO" + return 0 + elif [ "$red" -gt 0 ]; then + echo "VERDICT : $red gate(s) RED — NO-GO until resolved" + return 1 + else + echo "VERDICT : $unknown gate(s) UNCHECKABLE — operator must verify manually before decision call" + return 2 + fi +} + +if [ "$LOOP" != "1" ]; then + run_once + exit $? +fi + +# Continuous mode. +while true; do + run_once || true + echo "" + echo "next check in ${CHECK_INTERVAL}s — Ctrl-C to exit" + sleep "$CHECK_INTERVAL" +done diff --git a/scripts/soft-launch/send-invitations.sh b/scripts/soft-launch/send-invitations.sh new file mode 100755 index 000000000..23eeb224d --- /dev/null +++ b/scripts/soft-launch/send-invitations.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# send-invitations.sh — batch-insert beta invitations from a validated +# cohort CSV, generate unique invite codes, render personalised email +# bodies, optionally dispatch via SMTP. +# +# Wraps the validate-cohort.sh sanity check + a transactional INSERT +# into beta_invites + a per-recipient email render. Splits "generate +# the codes + render the emails" from "actually send" so a dry-run +# produces a flat directory of `.eml` files the operator can review +# before dispatch. +# +# v1.0.10 Cluster 3.4. +# +# Usage : +# # Step 1 : dry-run (default). Inserts beta_invites rows, emits +# # eml files but does NOT send anything. +# DATABASE_URL=postgres://... \ +# bash scripts/soft-launch/send-invitations.sh path/to/cohort.csv +# +# # Step 2 : after reviewing the eml files, dispatch with msmtp / +# # sendmail / aws-ses-cli (or whatever SEND_CMD points at). +# SEND=1 SEND_CMD='msmtp -t' \ +# bash scripts/soft-launch/send-invitations.sh path/to/cohort.csv +# +# Required env : +# DATABASE_URL Postgres URL (read+write to beta_invites) +# FRONTEND_URL base URL the invite link points at +# (e.g. https://staging.veza.fr) +# +# Optional env : +# SEND=1 actually dispatch ; otherwise dry-run (eml only) +# SEND_CMD sendmail-compatible command (default: 'msmtp -t') +# SENT_BY_EMAIL operator email for the beta_invites.sent_by FK ; +# defaults to the value in the CSV's third column +# FROM_ADDR From: header (default: invitations@veza.fr) +# SUBJECT email subject (default: 'You're in for the Veza beta') +# TEMPLATE path to eml template (default: +# templates/email/beta_invite.html.template) +# FORCE=1 skip validate-cohort.sh failures (use with care) +# +# Exit codes : +# 0 — everything succeeded +# 1 — cohort validation failed (see validate-cohort.sh) +# 2 — DB transaction failed +# 3 — required env missing +# 4 — dispatch failed for at least one recipient (see logs) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +CSV=${1:-} +if [ -z "$CSV" ] || [ ! -f "$CSV" ]; then + echo "usage: bash scripts/soft-launch/send-invitations.sh path/to/cohort.csv" >&2 + exit 3 +fi + +DATABASE_URL=${DATABASE_URL:-?} +FRONTEND_URL=${FRONTEND_URL:-?} +[ "$DATABASE_URL" = "?" ] && { echo "DATABASE_URL required" >&2; exit 3; } +[ "$FRONTEND_URL" = "?" ] && { echo "FRONTEND_URL required" >&2; exit 3; } + +SEND=${SEND:-0} +SEND_CMD=${SEND_CMD:-msmtp -t} +FROM_ADDR=${FROM_ADDR:-invitations@veza.fr} +SUBJECT=${SUBJECT:-Vous êtes admis dans la bêta Veza} +TEMPLATE=${TEMPLATE:-$REPO_ROOT/templates/email/beta_invite.eml.template} +FORCE=${FORCE:-0} +SESSION_DATE="$(date +%Y%m%d-%H%M)" +OUTDIR="$REPO_ROOT/scripts/soft-launch/out-${SESSION_DATE}" + +command -v psql >/dev/null 2>&1 || { echo "psql required" >&2; exit 3; } +command -v openssl >/dev/null 2>&1 || { echo "openssl required" >&2; exit 3; } + +# Step 1 — validate the cohort. Bypass with FORCE=1 if needed. +echo "→ validating cohort $CSV" +if ! bash "$(dirname "$0")/validate-cohort.sh" "$CSV"; then + if [ "$FORCE" != "1" ]; then + echo "ERROR: cohort validation failed. Re-run with FORCE=1 to bypass (not recommended)." >&2 + exit 1 + fi + echo "WARN : cohort validation reported issues but FORCE=1 set — proceeding." +fi + +mkdir -p "$OUTDIR" +echo "→ output dir $OUTDIR" + +# Step 2 — generate codes + insert rows + render emails. Each insert +# is one transaction so a partial failure leaves consistent state. +gen_code() { + # 16-char base32-ish (no 0/1/I/L) so codes are paste-friendly. + openssl rand -hex 16 | tr 'a-f0-9' 'a-z2-9' \ + | tr -d 'oilOIL01' | head -c 16 +} + +if [ ! -f "$TEMPLATE" ]; then + echo "ERROR: template $TEMPLATE not found." >&2 + exit 3 +fi + +inserted=0 +failed=0 +failed_emails=() + +while IFS=, read -r email cohort sent_by_email; do + email=$(echo "$email" | tr -d '\r' | xargs) + cohort=$(echo "$cohort" | tr -d '\r' | xargs) + sent_by_email=$(echo "$sent_by_email" | tr -d '\r' | xargs) + + code=$(gen_code) + + # Resolve sent_by user_id (may be NULL if operator email isn't a + # registered user — e.g. ops shared mailbox). + sent_by_id=$(psql "$DATABASE_URL" -A -t -c " + SELECT id::text FROM users WHERE email = '$sent_by_email' LIMIT 1; + " 2>/dev/null | tr -d ' ' || echo "") + + if [ -z "$sent_by_id" ]; then + sent_by_clause="NULL" + else + sent_by_clause="'$sent_by_id'" + fi + + if ! psql "$DATABASE_URL" -1 -c " + INSERT INTO beta_invites (code, email, cohort, sent_by, expires_at) + VALUES ('$code', '$email', '$cohort', $sent_by_clause, NOW() + INTERVAL '30 days'); + " >/dev/null 2>&1; then + failed=$(( failed + 1 )) + failed_emails+=("$email") + continue + fi + inserted=$(( inserted + 1 )) + + # Render the eml — operator-readable, ready for SEND_CMD. + eml="$OUTDIR/${email//[^a-zA-Z0-9._-]/_}.eml" + invite_url="$FRONTEND_URL/signup?invite=$code" + sed \ + -e "s|{{TO_ADDR}}|$email|g" \ + -e "s|{{FROM_ADDR}}|$FROM_ADDR|g" \ + -e "s|{{SUBJECT}}|$SUBJECT|g" \ + -e "s|{{INVITE_URL}}|$invite_url|g" \ + -e "s|{{INVITE_CODE}}|$code|g" \ + -e "s|{{COHORT}}|$cohort|g" \ + -e "s|{{FRONTEND_URL}}|$FRONTEND_URL|g" \ + "$TEMPLATE" > "$eml" +done < <(tail -n +2 "$CSV") + +echo "→ inserted $inserted invitations into beta_invites" +echo "→ rendered $inserted emails to $OUTDIR" +[ "$failed" -gt 0 ] && { + echo "WARN : $failed insert(s) failed — see logs above" + for e in "${failed_emails[@]}"; do echo " - $e"; done +} + +# Step 3 — optionally dispatch. +if [ "$SEND" != "1" ]; then + echo "" + echo "DRY-RUN — review the eml files in $OUTDIR before sending." + echo "When ready :" + echo " SEND=1 SEND_CMD='$SEND_CMD' bash scripts/soft-launch/send-invitations.sh $CSV" + exit 0 +fi + +echo "→ dispatching via : $SEND_CMD" +dispatch_failed=0 +for eml in "$OUTDIR"/*.eml; do + if ! $SEND_CMD < "$eml" >>"$OUTDIR/dispatch.log" 2>&1; then + dispatch_failed=$(( dispatch_failed + 1 )) + echo " FAIL : $eml" | tee -a "$OUTDIR/dispatch.log" + fi +done + +echo "" +if [ "$dispatch_failed" -gt 0 ]; then + echo "WARN : $dispatch_failed dispatch(es) failed — see $OUTDIR/dispatch.log" + exit 4 +fi +echo "PASS : all $inserted invitations dispatched." +echo "Track redemption with :" +echo " psql \"\$DATABASE_URL\" -c 'SELECT cohort, count(*) FILTER (WHERE used_at IS NOT NULL) AS redeemed, count(*) AS total FROM beta_invites GROUP BY cohort ORDER BY cohort;'" diff --git a/scripts/soft-launch/validate-cohort.sh b/scripts/soft-launch/validate-cohort.sh new file mode 100755 index 000000000..dd9604768 --- /dev/null +++ b/scripts/soft-launch/validate-cohort.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# validate-cohort.sh — sanity-check a soft-launch beta cohort CSV +# before it gets fed to send-invitations.sh. +# +# The CSV is the operator's curated list of beta-tester emails + +# segmentation. This script catches the avoidable mistakes BEFORE +# we batch-insert 100 rows into beta_invites and start spraying +# emails : +# +# - Empty file or wrong header +# - Duplicate emails (would create 2 invites for the same person) +# - Malformed emails (missing @, leading/trailing whitespace) +# - Cohort distribution looks off (no creators, only one segment, +# under-50 total — soft-launch acceptance gate is ≥50 testers) +# - Email collisions with existing users (already registered = the +# invite code is wasted) +# +# v1.0.10 Cluster 3.4. +# +# Usage : +# bash scripts/soft-launch/validate-cohort.sh path/to/cohort.csv +# +# Optional env : +# DATABASE_URL if set, also checks for collisions with the users +# table (email already registered → flagged but not +# fatal — operator may want to invite an existing +# user back to test the new flows). +# MIN_COHORT minimum total rows required (default 50, matches the +# acceptance-gate threshold in SOFT_LAUNCH_BETA_2026.md). +# MIN_CREATORS minimum number of creator-* cohort rows (default 5). +# +# Exit codes : +# 0 — cohort valid +# 1 — cohort malformed (will block send-invitations.sh) +# 2 — cohort merely warns (size below minimum, missing collision +# check) ; operator may proceed with --force +set -euo pipefail + +CSV=${1:-} +if [ -z "$CSV" ] || [ ! -f "$CSV" ]; then + cat >&2 <- so the post-launch attribution report groups cleanly. +EOF + exit 1 +fi + +MIN_COHORT=${MIN_COHORT:-50} +MIN_CREATORS=${MIN_CREATORS:-5} + +# 1. Header check. +header=$(head -1 "$CSV" | tr -d '\r') +if [ "$header" != "email,cohort,sent_by_email" ]; then + echo "ERROR: header line must be exactly 'email,cohort,sent_by_email' (got: $header)" >&2 + exit 1 +fi + +# 2. Row count + duplicates + email shape (awk pipeline reads once). +total=0 +malformed=0 +duplicates=0 +declare -A seen +declare -A cohort_count +declare -a malformed_lines + +while IFS=, read -r email cohort sent_by_email; do + email=$(echo "$email" | tr -d '\r' | xargs) + cohort=$(echo "$cohort" | tr -d '\r' | xargs) + + total=$(( total + 1 )) + + # Email shape : must contain exactly one @, no whitespace, > 5 chars. + if [[ ! "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then + malformed=$(( malformed + 1 )) + malformed_lines+=(" line $(( total + 1 )) : invalid email '$email'") + continue + fi + + # Duplicate detection. + if [ -n "${seen[$email]:-}" ]; then + duplicates=$(( duplicates + 1 )) + malformed_lines+=(" line $(( total + 1 )) : duplicate email '$email' (first seen at line ${seen[$email]})") + continue + fi + seen[$email]=$(( total + 1 )) + + # Cohort tally. + cohort_count[$cohort]=$(( ${cohort_count[$cohort]:-0} + 1 )) +done < <(tail -n +2 "$CSV") + +echo "----------------------------------------------------------------" +echo "Cohort validation report" +echo "----------------------------------------------------------------" +echo " CSV file : $CSV" +echo " Total rows : $total" +echo " Unique emails : ${#seen[@]}" +echo " Malformed rows : $malformed" +echo " Duplicates : $duplicates" +echo "" +echo "Distribution by cohort :" +for c in "${!cohort_count[@]}"; do + printf " %-40s %d\n" "$c" "${cohort_count[$c]}" +done | sort +echo "" + +exit_code=0 + +# 3. Hard checks (block send). +if [ "$malformed" -gt 0 ] || [ "$duplicates" -gt 0 ]; then + echo "ERROR: $malformed malformed + $duplicates duplicate row(s) — fix before sending." + for line in "${malformed_lines[@]}"; do + echo "$line" + done + exit 1 +fi + +# 4. Soft checks (warn, don't block — operator decides). +if [ "$total" -lt "$MIN_COHORT" ]; then + echo "WARN : cohort has $total rows ; soft-launch acceptance gate is ≥ $MIN_COHORT." + exit_code=2 +fi + +creator_total=0 +for c in "${!cohort_count[@]}"; do + if [[ "$c" == creator-* ]]; then + creator_total=$(( creator_total + cohort_count[$c] )) + fi +done +if [ "$creator_total" -lt "$MIN_CREATORS" ]; then + echo "WARN : only $creator_total creator-* cohort rows ; goal is ≥ $MIN_CREATORS for upload-flow coverage." + exit_code=2 +fi + +if [ "${#cohort_count[@]}" -lt 3 ]; then + echo "WARN : only ${#cohort_count[@]} distinct cohort labels — feedback will be narrow." + exit_code=2 +fi + +# 5. Optional : DATABASE_URL collision check. +if [ -n "${DATABASE_URL:-}" ]; then + command -v psql >/dev/null 2>&1 || { + echo "WARN : DATABASE_URL set but psql not on \$PATH ; skipping collision check." + exit_code=2 + } + if command -v psql >/dev/null 2>&1; then + emails_csv=$(printf '%s,' "${!seen[@]}" | sed 's/,$//') + collisions=$(psql "$DATABASE_URL" -A -t -c " + SELECT count(*) FROM users WHERE email = ANY(string_to_array('$emails_csv', ',')); + " 2>/dev/null | tr -d ' ' || echo "?") + if [ "$collisions" = "?" ]; then + echo "WARN : couldn't query users table (psql connection issue) ; skipping collision check." + exit_code=2 + elif [ "$collisions" -gt 0 ]; then + echo "INFO : $collisions email(s) in the cohort already exist in the users table — invite codes will be wasted on existing accounts." + exit_code=2 + fi + fi +fi + +echo "" +case $exit_code in + 0) echo "PASS : cohort valid, ready for send-invitations.sh." ;; + 2) echo "WARN : cohort valid but with caveats — review and re-run with --force from send-invitations.sh if intentional." ;; +esac +exit $exit_code diff --git a/veza-backend-api/migrations/990_beta_invites.sql b/veza-backend-api/migrations/990_beta_invites.sql new file mode 100644 index 000000000..1e262be46 --- /dev/null +++ b/veza-backend-api/migrations/990_beta_invites.sql @@ -0,0 +1,65 @@ +-- 990_beta_invites.sql +-- v1.0.10 polish (Cluster 3.4) — soft-launch beta cohort tracking. +-- +-- Records each individual invitation sent for the v2.0.0 soft-launch +-- bêta. Tracks (a) the invite code used in the registration link, +-- (b) when the recipient redeemed it (NULL until redemption), and +-- (c) which cohort segment (creator / listener / community-member / +-- press) the recipient belongs to so the post-launch report can +-- attribute feedback by audience. +-- +-- The associated email template + send script live at +-- scripts/soft-launch/send-invitations.sh and reference this table +-- via INSERT … RETURNING code. +-- +-- Privacy : the email column is the only PII here ; no behavioural +-- data is stored. used_at is the redemption signal. After v2.0.0 +-- public launch, run the cleanup migration in 991 (TBD) to anonymise +-- the email column for invites that haven't been redeemed in 30+ days. + +CREATE TABLE IF NOT EXISTS public.beta_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- The invite code is what the recipient pastes into the signup + -- form. 16 random characters from a base32 alphabet (no 0/1/I/L + -- to avoid eyestrain). Generated by send-invitations.sh. + code VARCHAR(32) NOT NULL UNIQUE, + email VARCHAR(320) NOT NULL, + -- Free-text label so the cohort generator can carry whatever + -- segmentation the operator wants (e.g. "creator-vinyl-pressing", + -- "listener-jazz-mailing-list", "press-pitchfork"). Index below + -- is for the post-launch report grouping. + cohort VARCHAR(64) NOT NULL, + -- NULL until the recipient signs up. Set by the auth handler + -- when /auth/register is hit with a valid invite code. + used_at TIMESTAMPTZ, + -- Hard expiry so unredeemed invites can't accumulate forever. + -- Default 30 days from creation ; soft-launch is short-window. + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '30 days'), + -- Operator who sent the invite — useful when reconciling "who + -- gave their friend a code" during the audit. + sent_by UUID REFERENCES public.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE public.beta_invites IS + 'v2.0.0 soft-launch beta invitation tracking. v1.0.10 Cluster 3.4.'; +COMMENT ON COLUMN public.beta_invites.code IS + '16-char base32 invite code (no 0/1/I/L). Pasted into signup form.'; +COMMENT ON COLUMN public.beta_invites.cohort IS + 'Free-text cohort label (creator-* / listener-* / press-* / etc.).'; +COMMENT ON COLUMN public.beta_invites.used_at IS + 'Redemption timestamp. NULL means the invite is still pending.'; + +-- Lookup by code (signup path) — every /auth/register call reads it. +CREATE UNIQUE INDEX IF NOT EXISTS idx_beta_invites_code + ON public.beta_invites(code); + +-- Cohort grouping for the post-launch attribution query. +CREATE INDEX IF NOT EXISTS idx_beta_invites_cohort + ON public.beta_invites(cohort); + +-- Pending-invitations sweep — cron job that expires unused invites +-- after expires_at. Partial index keeps it small. +CREATE INDEX IF NOT EXISTS idx_beta_invites_pending_expiry + ON public.beta_invites(expires_at) + WHERE used_at IS NULL; diff --git a/veza-backend-api/templates/email/beta_invite.eml.template b/veza-backend-api/templates/email/beta_invite.eml.template new file mode 100644 index 000000000..129c2875d --- /dev/null +++ b/veza-backend-api/templates/email/beta_invite.eml.template @@ -0,0 +1,92 @@ +To: {{TO_ADDR}} +From: Veza <{{FROM_ADDR}}> +Subject: {{SUBJECT}} +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="--veza-beta-boundary" + +----veza-beta-boundary +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: 7bit + +Bonjour, + +Vous êtes invité·e à rejoindre la bêta privée de Veza — +une plateforme de streaming musical éthique faite pour les +créateur·ices et les auditeur·ices, sans algorithme de +recommandation comportementale, sans gamification, sans dark +patterns. + +Votre code d'invitation : {{INVITE_CODE}} + +Pour vous inscrire : +{{INVITE_URL}} + +Le code expire dans 30 jours. + +Pendant la bêta, l'idée est simple : utilisez Veza comme vous +utiliseriez n'importe quelle plateforme musicale. Uploadez, +écoutez, partagez, achetez. Quand quelque chose vous frustre +ou vous étonne — en bien comme en mal — dites-le. Le canal +de retour vous sera communiqué après l'inscription. + +Cohorte : {{COHORT}} +(C'est juste un tag interne pour qu'on regroupe les retours +par contexte d'usage. Ça n'affecte ni votre expérience ni vos +permissions.) + +À très vite, +L'équipe Veza + + +-- +Si vous n'avez pas demandé cette invitation, ignorez ce +message. Le code expirera automatiquement après 30 jours. + +----veza-beta-boundary +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: 7bit + + + + + + + Invitation à la bêta Veza + + +
+

Bienvenue dans la bêta Veza.

+

Bonjour,

+

Vous êtes invité·e à rejoindre la bêta privée de Veza — une plateforme de streaming musical éthique faite pour les créateur·ices et les auditeur·ices, sans algorithme de recommandation comportementale, sans gamification, sans dark patterns.

+ + + +

Ou collez ce lien dans votre navigateur :

+

{{INVITE_URL}}

+ +

Code d'invitation :

+

{{INVITE_CODE}}

+ +
+ +

Pendant la bêta, l'idée est simple : utilisez Veza comme vous utiliseriez n'importe quelle plateforme musicale. Uploadez, écoutez, partagez, achetez. Quand quelque chose vous frustre ou vous étonne — en bien comme en mal — dites-le. Le canal de retour vous sera communiqué après l'inscription.

+ +

Cohorte : {{COHORT}} — c'est juste un tag interne pour qu'on regroupe les retours par contexte d'usage.

+ +

+ Le code expire dans 30 jours. Si vous n'avez pas demandé cette invitation, ignorez ce message. +

+ +
+

+ VEZA · v2.0.0 BETA · {{FRONTEND_URL}} +

+
+ + + +----veza-beta-boundary--