feat(soft-launch): cohort tooling + email template + monitor + checklist
Some checks are pending
Veza CI / Backend (Go) (push) Waiting to run
Veza CI / Frontend (Web) (push) Waiting to run
Veza CI / Rust (Stream Server) (push) Waiting to run
Veza CI / Notify on failure (push) Blocked by required conditions
E2E Playwright / e2e (full) (push) Waiting to run
Security Scan / Secret Scanning (gitleaks) (push) Waiting to run

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-<date>/
      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) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-30 22:38:12 +02:00
parent 2a5bc11628
commit 112c64a22b
6 changed files with 914 additions and 0 deletions

View file

@ -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=<test-code>`.
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

View file

@ -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 "✅ <name>" / "🔴 <name>" / "⚪ <name>"
# (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

View file

@ -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;'"

View file

@ -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 <<EOF
usage : bash scripts/soft-launch/validate-cohort.sh path/to/cohort.csv
CSV format (header required) :
email,cohort,sent_by_email
alice@example.com,creator-vinyl,ops@veza.fr
bob@example.com,listener-jazz,ops@veza.fr
...
cohort labels are free-text but should follow the convention
<role>-<segment> 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

View file

@ -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;

View file

@ -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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invitation à la bêta Veza</title>
</head>
<body style="font-family: Georgia, 'Times New Roman', serif; line-height: 1.6; color: #1a1a1e; margin: 0; padding: 0; background-color: #f8f7f4;">
<div style="max-width: 600px; margin: 20px auto; padding: 30px; background-color: #ffffff; border: 1px solid #e8e6e0;">
<h1 style="font-weight: 400; color: #1a1a1e; margin-top: 0; font-size: 28px;">Bienvenue dans la bêta Veza.</h1>
<p>Bonjour,</p>
<p>Vous êtes invité·e à rejoindre la <strong>bêta privée</strong> 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.</p>
<div style="text-align: center; margin: 35px 0;">
<a href="{{INVITE_URL}}" style="background-color: #1a1a1e; color: #f8f7f4; padding: 14px 32px; text-decoration: none; display: inline-block; font-weight: 400; letter-spacing: 0.05em;">
Activer mon invitation
</a>
</div>
<p style="color: #555; font-size: 14px;">Ou collez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; color: #888; background-color: #f8f7f4; padding: 10px; font-family: 'Courier New', monospace; font-size: 12px; border-left: 2px solid #d4a574;">{{INVITE_URL}}</p>
<p style="color: #555; font-size: 14px; margin-top: 25px;">Code d'invitation :</p>
<p style="font-family: 'Courier New', monospace; font-size: 18px; letter-spacing: 0.1em; background-color: #f8f7f4; padding: 12px; text-align: center; color: #1a1a1e;">{{INVITE_CODE}}</p>
<hr style="border: none; border-top: 1px solid #e8e6e0; margin: 30px 0;">
<p style="font-size: 14px; color: #555;">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.</p>
<p style="font-size: 13px; color: #888; margin-top: 25px;">Cohorte : <strong>{{COHORT}}</strong> — c'est juste un tag interne pour qu'on regroupe les retours par contexte d'usage.</p>
<p style="margin-top: 30px; color: #888; font-size: 12px;">
Le code expire dans 30 jours. Si vous n'avez pas demandé cette invitation, ignorez ce message.
</p>
<hr style="border: none; border-top: 1px solid #e8e6e0; margin: 25px 0;">
<p style="color: #aaa; font-size: 11px; text-align: center; font-family: 'Courier New', monospace; letter-spacing: 0.1em;">
VEZA · v2.0.0 BETA · {{FRONTEND_URL}}
</p>
</div>
</body>
</html>
----veza-beta-boundary--