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
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:
parent
2a5bc11628
commit
112c64a22b
6 changed files with 914 additions and 0 deletions
150
docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md
Normal file
150
docs/SOFT_LAUNCH_BETA_2026_CHECKLIST.md
Normal 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
|
||||
255
scripts/soft-launch/monitor-checks.sh
Executable file
255
scripts/soft-launch/monitor-checks.sh
Executable 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
|
||||
179
scripts/soft-launch/send-invitations.sh
Executable file
179
scripts/soft-launch/send-invitations.sh
Executable 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;'"
|
||||
173
scripts/soft-launch/validate-cohort.sh
Executable file
173
scripts/soft-launch/validate-cohort.sh
Executable 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
|
||||
65
veza-backend-api/migrations/990_beta_invites.sql
Normal file
65
veza-backend-api/migrations/990_beta_invites.sql
Normal 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;
|
||||
92
veza-backend-api/templates/email/beta_invite.eml.template
Normal file
92
veza-backend-api/templates/email/beta_invite.eml.template
Normal 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--
|
||||
Loading…
Reference in a new issue