docs: spec for beta indicator + paid/free gap (digests + news cap)
Design doc for three coordinated closed-beta changes: a BETA chip in the app header, a 6h news-window cap on the free tier, and email digests (daily for paid Mon-Sat, Sunday weekly for everyone). Draft; awaits implementation plan. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
71a2fc5b51
commit
eabf8b6a7f
1 changed files with 338 additions and 0 deletions
|
|
@ -0,0 +1,338 @@
|
||||||
|
# Beta Mode + Wider Paid/Free Gap — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-05-25
|
||||||
|
**Status:** Draft — awaiting approval
|
||||||
|
**Scope:** Three coordinated changes that ship together because they all
|
||||||
|
target the closed-beta launch: (1) a visible BETA indicator in the app
|
||||||
|
chrome, (2) a free-tier cap on the news feed window, and (3) email
|
||||||
|
digests — daily for paid, Sunday-weekly for everyone.
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
- Set expectations for closed-beta testers that the product is still
|
||||||
|
evolving, without making the app look unfinished.
|
||||||
|
- Create a tangible reason to upgrade beyond portfolio import + sync.
|
||||||
|
Today the free tier carries nearly the entire editorial layer; after
|
||||||
|
this change, paid gets meaningfully more.
|
||||||
|
- Use existing infra (scheduler, SMTP, OpenRouter, settings page)
|
||||||
|
rather than introducing new dependencies.
|
||||||
|
|
||||||
|
## 2. Non-goals
|
||||||
|
|
||||||
|
- No payment / Paddle work. Pricing copy is updated, but checkout is
|
||||||
|
still gated behind the existing "Coming soon" CTA.
|
||||||
|
- No per-user personalised digests. Same content for all recipients.
|
||||||
|
- No timezone handling for users. Fixed 06:30 UTC daily send.
|
||||||
|
- No new analytics / metrics dashboards for opens or clicks beyond a
|
||||||
|
simple audit row per send.
|
||||||
|
|
||||||
|
## 3. Component overview
|
||||||
|
|
||||||
|
| Component | Change |
|
||||||
|
|-----------|--------|
|
||||||
|
| `app/templates/base.html` | BETA chip in header next to brand. |
|
||||||
|
| `app/static/css/cassandra.css` | `.beta-chip` styles. |
|
||||||
|
| `app/config.py` | `BETA_MODE: bool = True` env flag. |
|
||||||
|
| `app/services/access.py` | `FREE_NEWS_WINDOW_HOURS = 6` constant. |
|
||||||
|
| `app/routers/api.py:222-280` (`news_list`) | Soft-auth dep + free-tier window clamp. |
|
||||||
|
| `app/templates/partials/news.html` | "Showing last 6h — upgrade for 24h" footer when capped. |
|
||||||
|
| `app/models.py` | New columns on `User`: `email_digest_opt_in: bool`, `digest_tone: str | None`. New table `EmailSend(user_id, kind, sent_at, status, error)`. |
|
||||||
|
| `alembic/versions/0017_email_digest.py` | Migration. |
|
||||||
|
| `app/services/openrouter.py` | Add `build_daily_digest_prompt(tone)` and `build_weekly_digest_prompt(tone)`, bump `PROMPT_VERSION`. |
|
||||||
|
| `app/services/email_service.py` | Add `render_digest_email(kind, tone, content)` + `send_digest(user, kind, tone, html, text)`. |
|
||||||
|
| `app/jobs/email_digest_job.py` (new) | Daily-or-weekly orchestrator. Generates content once per tone, fans out to recipients. |
|
||||||
|
| `app/scheduler_main.py` | Register the new job at 06:30 UTC. |
|
||||||
|
| `app/templates/settings.html` | "Email digests" section: opt-in toggle, tone radio. |
|
||||||
|
| `app/templates/login.html` (post-OTP-verify flow) | Default-checked "send me the digest" checkbox. |
|
||||||
|
| `app/routers/auth.py` | OTP-verify handler reads the new `subscribe_to_digests` form field and sets `email_digest_opt_in` on the new `User`. |
|
||||||
|
| `app/routers/settings.py` (or wherever existing settings PATCH lives) | New `PATCH /api/settings/digest` endpoint: body `{opt_in: bool, tone: "NOVICE"|"INTERMEDIATE"}`. |
|
||||||
|
| `app/routers/email.py` (new) | `GET /email/unsubscribe?token=...` — HMAC-verified one-click off switch. |
|
||||||
|
| `app/templates/pricing.html` | Updated bullets — free gets weekly digest + last 6h news; paid gets daily digest + last 24h news. |
|
||||||
|
|
||||||
|
## 4. Detailed design
|
||||||
|
|
||||||
|
### 4.1 BETA chip
|
||||||
|
|
||||||
|
`app/templates/base.html` only (not `public_base.html`). Insert
|
||||||
|
immediately after the brand link:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/" class="brand">{{ BRAND_NAME }}</a>
|
||||||
|
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome">BETA</span>{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `BETA_MODE` flag is injected into the template context globally —
|
||||||
|
add it to `app/templates_env.py` so every render gets it (analogous to
|
||||||
|
how `BRAND_NAME` is provided today).
|
||||||
|
|
||||||
|
CSS: small uppercase pill with `var(--accent)` background and brand-bg
|
||||||
|
foreground; spaced ~8px from the brand link. Sizing matches the
|
||||||
|
existing top-right `.meta` chip so the visual weight is balanced.
|
||||||
|
|
||||||
|
`BETA_MODE` defaults to `True` in `app/config.py`. Flip to `False` for
|
||||||
|
general launch — one-line change.
|
||||||
|
|
||||||
|
### 4.2 Free-tier news cap (6h window)
|
||||||
|
|
||||||
|
`app/routers/api.py`'s `news_list` (lines 222-280) currently has no
|
||||||
|
auth dep. Add `principal: CurrentUser | None = Depends(maybe_current_user)`
|
||||||
|
(soft-auth — keeps the endpoint reachable by anonymous visitors,
|
||||||
|
matching today's behaviour).
|
||||||
|
|
||||||
|
Compute the effective window:
|
||||||
|
|
||||||
|
```python
|
||||||
|
window = since_hours
|
||||||
|
if not is_paid_active(principal):
|
||||||
|
window = min(window, FREE_NEWS_WINDOW_HOURS) # 6.0
|
||||||
|
cutoff = utcnow() - timedelta(hours=window)
|
||||||
|
```
|
||||||
|
|
||||||
|
`FREE_NEWS_WINDOW_HOURS = 6.0` lives in `app/services/access.py` as a
|
||||||
|
module-level constant. Tuning it later is one edit; promoting it to env
|
||||||
|
config is YAGNI for now.
|
||||||
|
|
||||||
|
`is_paid_active` already accepts `CurrentUser | User | None` and admins
|
||||||
|
auto-pass — no special-casing needed here.
|
||||||
|
|
||||||
|
When the cap is in effect, pass `capped: True` into the news partial so
|
||||||
|
it can render a soft footer:
|
||||||
|
|
||||||
|
> Free tier — showing the last 6 hours of news. [Upgrade] for the full
|
||||||
|
> 24-hour feed plus daily and weekly email digests.
|
||||||
|
|
||||||
|
When the user is paid (or admin), the footer is absent. When the
|
||||||
|
visitor is anonymous, the link goes to `/pricing`.
|
||||||
|
|
||||||
|
### 4.3 Database changes
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/models.py — User columns added:
|
||||||
|
email_digest_opt_in: Mapped[bool] = mapped_column(Boolean, nullable=False,
|
||||||
|
default=True, server_default=text("1"))
|
||||||
|
digest_tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE | INTERMEDIATE
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to opted-in (matches the chosen UX). `digest_tone` is nullable;
|
||||||
|
NULL is interpreted as INTERMEDIATE at render time.
|
||||||
|
|
||||||
|
New table:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EmailSend(Base):
|
||||||
|
__tablename__ = "email_sends"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), nullable=False) # "daily" | "weekly"
|
||||||
|
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
|
||||||
|
default=utcnow, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False) # "sent" | "skipped" | "error"
|
||||||
|
error: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotency: the job queries `EmailSend` for the current day before
|
||||||
|
sending, so a job restart can't double-deliver.
|
||||||
|
|
||||||
|
Migration: `alembic/versions/0017_email_digest.py`.
|
||||||
|
|
||||||
|
### 4.4 Digest content generation
|
||||||
|
|
||||||
|
Two new prompts in `app/services/openrouter.py`:
|
||||||
|
|
||||||
|
- `build_daily_digest_prompt(tone)` — returns a `(system, user)` pair.
|
||||||
|
Pulls the same `quotes_by_group` + `headlines_by_bucket` data as
|
||||||
|
the hourly log but with a 24h headline window and a different
|
||||||
|
instruction set: less "current state" framing, more "what mattered in
|
||||||
|
the past day, what to watch today". Target length ~600 words.
|
||||||
|
- `build_weekly_digest_prompt(tone)` — 7-day headline window, weekly
|
||||||
|
recap + week-ahead anticipation. Target length ~900 words.
|
||||||
|
|
||||||
|
Both reuse `call_llm()` and the existing cost-cap / ledger plumbing.
|
||||||
|
`PROMPT_VERSION` is bumped so the audit trail is unambiguous.
|
||||||
|
|
||||||
|
For each digest run, the job generates **two** variants
|
||||||
|
(NOVICE + INTERMEDIATE) and stores them in memory for the fan-out
|
||||||
|
batch. There is no DB persistence of digest content — emails are the
|
||||||
|
artefact. If we later want to render them on a web archive, that's a
|
||||||
|
separate spec.
|
||||||
|
|
||||||
|
Cost: at our current model pricing, two daily generations ≈ $0.04/day,
|
||||||
|
two weekly generations ≈ $0.06/week. Both well under the existing
|
||||||
|
`OPENROUTER_MONTHLY_CAP_USD` headroom.
|
||||||
|
|
||||||
|
### 4.5 Email rendering and delivery
|
||||||
|
|
||||||
|
`render_digest_email(kind, tone, html_body, text_body) -> (subject,
|
||||||
|
text, html)` in `email_service.py`. Wraps the LLM output in the same
|
||||||
|
multipart template family used for OTPs (light/dark palette, inline
|
||||||
|
styles, monospace stack, 520px max-width). Adds two footer rows:
|
||||||
|
|
||||||
|
- "Don't want these? [Unsubscribe in one click](.../email/unsubscribe?token=...)"
|
||||||
|
- "Or change your preferences in [Settings](.../settings)"
|
||||||
|
|
||||||
|
Subjects:
|
||||||
|
|
||||||
|
- Daily: `"Read the Markets · Daily — {date}"`
|
||||||
|
- Weekly: `"Read the Markets · Weekly recap — {date}"`
|
||||||
|
|
||||||
|
Fan-out: one SMTP send per recipient. Sequential with a small
|
||||||
|
`asyncio.sleep(0.1)` between sends to stay under common SMTP rate
|
||||||
|
limits. Failures are caught per-recipient, logged into `EmailSend`,
|
||||||
|
and don't block the rest of the batch.
|
||||||
|
|
||||||
|
### 4.6 Sign-up opt-in checkbox
|
||||||
|
|
||||||
|
The OTP-verify POST handler in `app/routers/auth.py` is where the user
|
||||||
|
is first established — that handler reads a `subscribe_to_digests`
|
||||||
|
form field and persists it into `User.email_digest_opt_in`. Default to
|
||||||
|
`True` if the field is absent (covers older clients or curl flows).
|
||||||
|
The verify template (`app/templates/verify.html`) gets a checkbox:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="subscribe_to_digests" checked>
|
||||||
|
Email me the digest (daily for paid, Sunday for everyone). One-click
|
||||||
|
unsubscribe in every email.
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-existing users get `email_digest_opt_in=True` from the migration's
|
||||||
|
server-side default — but see §6 for the cutover plan.
|
||||||
|
|
||||||
|
### 4.7 Settings page
|
||||||
|
|
||||||
|
In `app/templates/settings.html`, add a section:
|
||||||
|
|
||||||
|
```
|
||||||
|
Email digests
|
||||||
|
[✓] Send me digests
|
||||||
|
Free tier: Sunday weekly. Paid: daily + Sunday.
|
||||||
|
Reading level: ( ) Novice (•) Intermediate
|
||||||
|
Last delivery: 2026-05-24 06:30 UTC — sent
|
||||||
|
```
|
||||||
|
|
||||||
|
The "Last delivery" row reads the most recent `EmailSend` row for this
|
||||||
|
user. If none, shows "—".
|
||||||
|
|
||||||
|
Wire it via the existing settings JS pattern (look for the sync /
|
||||||
|
tone-toggle handlers in `static/js/`); the endpoints are
|
||||||
|
`PATCH /api/settings/digest` with body `{opt_in: bool, tone: str}`.
|
||||||
|
|
||||||
|
### 4.8 One-click unsubscribe
|
||||||
|
|
||||||
|
`GET /email/unsubscribe?token=<base64>`:
|
||||||
|
|
||||||
|
- Token is `itsdangerous.URLSafeSerializer` over `{"uid": user_id,
|
||||||
|
"purpose": "digest_optout"}`, signed with `CASSANDRA_SECRET`.
|
||||||
|
- Handler verifies, flips `email_digest_opt_in=False`, renders a tiny
|
||||||
|
confirmation page ("You're unsubscribed. Re-enable any time in
|
||||||
|
[Settings](/settings).").
|
||||||
|
- No auth required — that's the whole point of one-click unsubscribe.
|
||||||
|
- Replay-safe: re-running the same URL is idempotent (the column
|
||||||
|
is already false; the page renders the same confirmation).
|
||||||
|
|
||||||
|
### 4.9 Scheduler integration
|
||||||
|
|
||||||
|
`app/scheduler_main.py` already runs the hourly jobs. Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
schedule_daily( # whatever helper exists, or apscheduler equivalent
|
||||||
|
"email_digest_job",
|
||||||
|
hour=6, minute=30,
|
||||||
|
target=email_digest_job.run,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The job itself decides what to do:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def run():
|
||||||
|
today = utcnow().date()
|
||||||
|
if today.weekday() == 6: # Sunday — weekly digest for everyone
|
||||||
|
await _run_weekly()
|
||||||
|
else:
|
||||||
|
await _run_daily() # paid only
|
||||||
|
```
|
||||||
|
|
||||||
|
`_run_weekly()` queries all users with `email_digest_opt_in=True`.
|
||||||
|
`_run_daily()` queries paid users with `email_digest_opt_in=True`.
|
||||||
|
|
||||||
|
### 4.10 Pricing copy updates
|
||||||
|
|
||||||
|
`app/templates/pricing.html` — modify the bullet lists. New copy:
|
||||||
|
|
||||||
|
Free:
|
||||||
|
- "News aggregator — last 6 hours, auto-tagged by theme" (was: no time qualifier)
|
||||||
|
- "Cross-asset macro signals across every asset class"
|
||||||
|
- "Hourly AI interpretation of the news + the tape"
|
||||||
|
- "Per-group cross-asset summaries"
|
||||||
|
- "Novice / Intermediate reading levels"
|
||||||
|
- "**Sunday weekly digest by email**" *(new)*
|
||||||
|
- "❌ Portfolio import & analysis"
|
||||||
|
- "❌ Encrypted cloud sync"
|
||||||
|
|
||||||
|
Paid (replaces the "Priority email when something material changes (later)" line):
|
||||||
|
- "Everything in Free"
|
||||||
|
- "**News aggregator — full 24 hours**" *(replaces the implicit 24h)*
|
||||||
|
- "Portfolio import (Trading 212 CSV)"
|
||||||
|
- "AI commentary on diversification, sector and currency concentration, …"
|
||||||
|
- "Optional encrypted cloud sync across devices"
|
||||||
|
- "**Daily email digest** (Mon–Sat) + Sunday weekly" *(replaces 'Priority email')*
|
||||||
|
|
||||||
|
The intro paragraph at lines 8-13 needs to soften:
|
||||||
|
|
||||||
|
> Two tiers. The news aggregator and hourly AI interpretation are
|
||||||
|
> available to everyone — paid extends the time window from 6h to 24h
|
||||||
|
> and adds daily editorial by email, plus the portfolio-import features.
|
||||||
|
|
||||||
|
(Old copy said "free for everyone — we want the read out where people
|
||||||
|
can use it." That stance is moderated, not abandoned.)
|
||||||
|
|
||||||
|
## 5. Error handling
|
||||||
|
|
||||||
|
- **SMTP failure**: per-recipient try/except. Log to `EmailSend` with
|
||||||
|
`status="error"`, `error=str(exc)[:255]`. Job continues. Job-level
|
||||||
|
failure metrics surface via existing `JobRun` mechanism.
|
||||||
|
- **OpenRouter failure**: if the content generation fails for both
|
||||||
|
tones, the job records `JobRun.status="error"` and sends nothing.
|
||||||
|
Half-success (one tone) → send the variant that worked, skip the
|
||||||
|
other; users on the failed tone get nothing today (rather than wrong
|
||||||
|
content).
|
||||||
|
- **Cost-cap hit**: same pattern as the hourly job — skip the run with
|
||||||
|
a logged reason.
|
||||||
|
- **Unsubscribe token invalid / tampered**: render the same
|
||||||
|
confirmation page generically; do not leak whether the token was
|
||||||
|
valid (avoid enumeration).
|
||||||
|
|
||||||
|
## 6. Cutover plan
|
||||||
|
|
||||||
|
- Migration sets `email_digest_opt_in=True` for all existing users via
|
||||||
|
`server_default`. This is the user-requested default — they want
|
||||||
|
every paid beta-tester actually receiving the email.
|
||||||
|
- BETA mode is on from the first deploy.
|
||||||
|
- News cap is on from the first deploy.
|
||||||
|
- The first daily run lands at 06:30 UTC the morning after deploy.
|
||||||
|
- The first weekly run lands at 06:30 UTC the next Sunday.
|
||||||
|
- A pre-deploy admin CLI command (`python -m app.cli send-test-digest
|
||||||
|
--email me@…`) is added for the operator to dry-run a digest into
|
||||||
|
their own inbox before flipping the scheduler.
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
- Unit: `is_paid_active` window-clamping, opt-in flag round-trip,
|
||||||
|
token signing/verification.
|
||||||
|
- Integration: `tests/test_news_api.py` — anonymous + free vs paid
|
||||||
|
windowing. `tests/test_email_digest.py` — job runs, EmailSend rows
|
||||||
|
written, idempotency on re-run within the same day.
|
||||||
|
- Manual: send-test-digest CLI, click unsubscribe link, verify Settings
|
||||||
|
toggle round-trips.
|
||||||
|
|
||||||
|
## 8. Open questions
|
||||||
|
|
||||||
|
None at design time — all earlier ambiguities (chip scope, window
|
||||||
|
shape, content shape, send time, tone variants, opt-in default,
|
||||||
|
unsubscribe model) were resolved during brainstorming.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue