Files
silo-server/internal/userdb/migrate.go
T
271a2e1741 feat: emailed invitations, claim + household setup, and server-driven onboarding tour (#501)
* feat(invitations): add emailed pre-provisioned invitations

Admins can invite a specific person by email: the invitation pre-binds
role, access group, and library access, and the invitee only chooses a
password. Their email address becomes their username, so login gains an
email fallback (username lookup first, email column only on miss for
inputs that parse as a bare address).

- invitations table: single-use token (SHA-256 at rest) bound to one
  address; a partial unique index makes resend-supersedes atomic; no
  users row exists until accept, so a typo'd address can't squat a
  username. Status is derived from timestamps, not stored.
- internal/invitations: repository, service, and branded email through
  the shared internal/mail sender. When SMTP is off the claim URL is
  returned for manual delivery instead of failing.
- Admin endpoints /admin/invitations (list/create/resend/revoke) beside
  the existing invite-codes routes; public claim endpoints
  /invitations/{token} (+/accept) rate-limited with the other auth
  endpoints. Unknown/expired/revoked/used tokens are indistinguishable.
- Accept returns the same login response shape as signup, so clients
  reuse their session plumbing.

Spec: docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md
Plan: docs/superpowers/plans/2026-07-27-invitations-and-onboarding.md
Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): add invitation admin tab, claim page, and household setup

- Admin → Users gains an Invitations tab: compose (email, role, access
  group, libraries, note, first-profile and tour toggles), list with
  derived status, resend, revoke. When the server has no SMTP the create
  response's claim URL is surfaced for copy-paste instead of a fake
  success.
- /invite/:token claim page: everything but the password was decided at
  send time, so it asks for exactly one thing and lands the user signed
  in. Expired/used links get an explanatory card, not a 404.
- /household-setup ("Who's watching?"): profile tiles plus the existing
  ProfileEditorDialog, all through the existing /profiles endpoint —
  no new backend. "Just me for now" is a first-class exit.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): add server-driven onboarding manifest and state

GET /onboarding/flow returns the ordered first-run tour for this server
and profile: steps for disabled features (requests, watch together,
recommendations, notifications) are filtered out server-side, surface=tv
drops steps needing text entry, and child profiles never see stops they
can't act on. Copy lives in Go, so a wording fix is a deploy — clients
render step kinds they know and skip unknown ones by contract.

setting_choice steps name an explicit write target (profile_field /
setting / device_setting) because playback quality is a profile column,
not a settings key — the tour writes through the same APIs the settings
screens use.

Per-profile completion state lives in the user store (SQLite schema v14
+ a Postgres twin table), keyed by (profile_id, tour_id) with monotonic
completed/skipped timestamps: finishing on one device silences every
other; a later progress write can never un-complete.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): add the first-run feature tour

TourHost renders the server manifest as a modal overlay on Home: unknown
step kinds are skipped silently (the forward-compat contract), progress
posts per step, and setting_choice steps write real values through the
existing profile/settings mutations — by the last step the account is
genuinely configured. Skip is always one click and recorded server-side,
so no other device re-prompts. The tour ends by handing off to the
existing taste-seed picker, which now waits for the tour to finish
before its own redirect. Settings → Personalize gains a replay entry.

An invitation sent with show_tour=false plants a local hint that the
gate converts into a server-side skip for the first profile.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): satisfy noUncheckedIndexedAccess in the tour's advance step

The Docker web build runs `tsc -b`, which applies the project's
noUncheckedIndexedAccess; the bounds check didn't narrow steps[next].
Look the step up once and branch on its presence instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): blur the whole app behind the tour, sidebar included

The tour overlay rendered inside the app layout, where an ancestor
creates a fixed-position containing block — inset-0 pinned to the
content pane, leaving the sidebar completely un-scrimmed. Portal the
dialog to <body> so the scrim truly covers the viewport, and raise the
backdrop blur from sm (4px) to xl (24px) so card titles and nav labels
aren't legible through it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(onboarding): name features by their UI labels in the tour copy

"Same movie, different couches" never said what the feature is called.
Every feature card now leads with the name the sidebar actually uses —
Watch Party, Requests, Watchlist, Calendar, Notifications — and says
where to find it, so the tour teaches vocabulary, not just concepts.
Server-side copy, so all three clients pick this up with no release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): add apps and Jellyfin-compat steps to the tour

Two new web-only feature cards near the end of the tour:

- "Take Silo with you" — native apps for iPhone/iPad/Apple TV and
  Android/Android TV, with outbound TestFlight and Play Store links.
  Steps gain an additive links field (label + url) that older clients
  ignore; the web TourHost renders them as external-link buttons.
- "Already use a Jellyfin app? It works here" — Infuse/VidHub/Findroid/
  Swiftfin connect via the Jellyfin API. Gated on
  jellyfin_compat.enabled (default-on, so unset counts as enabled;
  only an explicit "false" hides it).

Both steps are web-only: the apps card is pointless inside the apps it
advertises, and TV can't open store links. surface=phone/tv manifests
skip them, covered by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): keep the tour card responsive on phone widths

Verified every step at 1600px, 390px, and 320px with an automated
overflow check. Fixes it found:

- Link buttons (apps step) now wrap and truncate instead of extending
  past the card edge.
- The footer wraps at very narrow widths, so the handoff step's wide
  primary button drops to its own line rather than overflowing.
- Progress pips hide on phones — decorative, and they crowded the
  Back/Next buttons.
- The card scrolls within 85dvh so a tall step never pins its buttons
  off-screen on landscape phones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): render store links as branded badges in the tour

The apps step's plain outline buttons now render as store badges: the
Apple or Google Play mark with a store eyebrow (TestFlight beta /
Google Play) over the platform label — the familiar app-store badge
idiom. The brand is inferred from the link's host on the client, so
the server contract stays icon-free and non-store links keep the plain
external-link button. Labels drop the parenthesized store name the
eyebrow now carries.

Verified at 1600px and 390px with the overflow sweep: none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:58:18 -04:00

404 lines
12 KiB
Go

package userdb
import (
"database/sql"
"fmt"
)
const schemaVersion = 14
func runMigrations(db *sql.DB) error {
version, err := userVersion(db)
if err != nil {
return err
}
if version > schemaVersion {
return fmt.Errorf("unsupported sqlite schema version %d", version)
}
if version == 0 {
return setUserVersion(db, schemaVersion)
}
if version == schemaVersion {
return nil
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("beginning sqlite migration transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck
if version < 2 {
if err := migrateToV2(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 2"); err != nil {
return fmt.Errorf("setting sqlite user_version 2: %w", err)
}
}
if version < 3 {
if err := migrateToV3(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 3"); err != nil {
return fmt.Errorf("setting sqlite user_version 3: %w", err)
}
}
if version < 4 {
if err := migrateToV4(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 4"); err != nil {
return fmt.Errorf("setting sqlite user_version 4: %w", err)
}
}
if version < 5 {
if err := migrateToV5(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 5"); err != nil {
return fmt.Errorf("setting sqlite user_version 5: %w", err)
}
}
if version < 6 {
if err := migrateToV6(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 6"); err != nil {
return fmt.Errorf("setting sqlite user_version 6: %w", err)
}
}
if version < 7 {
if err := migrateToV7(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 7"); err != nil {
return fmt.Errorf("setting sqlite user_version 7: %w", err)
}
}
if version < 8 {
if err := migrateToV8(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 8"); err != nil {
return fmt.Errorf("setting sqlite user_version 8: %w", err)
}
}
if version < 9 {
if _, err := tx.Exec("PRAGMA user_version = 9"); err != nil {
return fmt.Errorf("setting sqlite user_version 9: %w", err)
}
}
if version < 10 {
if err := migrateToV10(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 10"); err != nil {
return fmt.Errorf("setting sqlite user_version 10: %w", err)
}
}
if version < 11 {
if err := migrateToV11(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 11"); err != nil {
return fmt.Errorf("setting sqlite user_version 11: %w", err)
}
}
if version < 12 {
if err := migrateToV12(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 12"); err != nil {
return fmt.Errorf("setting sqlite user_version 12: %w", err)
}
}
if version < 13 {
if err := migrateToV13(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 13"); err != nil {
return fmt.Errorf("setting sqlite user_version 13: %w", err)
}
}
if version < 14 {
if err := migrateToV14(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 14"); err != nil {
return fmt.Errorf("setting sqlite user_version 14: %w", err)
}
}
return tx.Commit()
}
// migrateToV14 adds the per-profile onboarding-tour state table. Keyed by
// (profile_id, tour_id): finishing or skipping the tour on one device is
// respected everywhere, and a future materially-different tour can use a new
// tour_id without clobbering history.
func migrateToV14(tx *sql.Tx) error {
_, err := tx.Exec(`CREATE TABLE IF NOT EXISTS profile_onboarding (
profile_id TEXT NOT NULL,
tour_id TEXT NOT NULL,
last_step TEXT NOT NULL DEFAULT '',
completed_at TEXT,
skipped_at TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, tour_id)
)`)
if err != nil {
return fmt.Errorf("creating profile_onboarding: %w", err)
}
return nil
}
// migrateToV13 replaces the v1 watch_progress stamp triggers with the current
// bodies (CREATE TRIGGER IF NOT EXISTS never replaces an existing trigger, and
// InitSchema runs before migrations, so this must drop AND recreate). v2 makes
// the UPDATE trigger authoritative for event_at: a write that advances
// updated_at without explicitly changing event_at advances the LWW key too,
// so queued offline events older than that write can no longer win
// SetProgressIfNewer and resurrect stale progress.
func migrateToV13(tx *sql.Tx) error {
for _, trg := range []string{"watch_progress_stamp_ins", "watch_progress_stamp_upd"} {
if _, err := tx.Exec("DROP TRIGGER IF EXISTS " + trg); err != nil {
return fmt.Errorf("dropping %s: %w", trg, err)
}
}
if _, err := tx.Exec(watchProgressSyncTriggers); err != nil {
return fmt.Errorf("reinstalling watch_progress sync triggers: %w", err)
}
return nil
}
// migrateToV12 adds the nullable watchlist.sort_index column used to mirror a
// provider's watchlist order. NULL means "use added_at ordering".
func migrateToV12(tx *sql.Tx) error {
if columnExists(tx, "watchlist", "sort_index") {
return nil
}
if _, err := tx.Exec("ALTER TABLE watchlist ADD COLUMN sort_index INTEGER"); err != nil {
return fmt.Errorf("adding watchlist.sort_index: %w", err)
}
return nil
}
// migrateToV11 resets legacy completed watch_progress rows to
// position_seconds = 0. The watch-progress model keeps `completed` as a
// one-way watched latch with no resume point; the Continue Watching
// predicate is position_seconds > 0, so legacy rows (position pinned to the
// duration) would otherwise surface as phantom resume entries. Running this
// as a versioned migration (rather than a predicate-only data fix) matters:
// re-running the UPDATE on every boot would wipe the resume point of any
// rewatch in flight.
func migrateToV11(tx *sql.Tx) error {
if _, err := tx.Exec(`
UPDATE watch_progress
SET position_seconds = 0
WHERE completed = 1
AND position_seconds <> 0`); err != nil {
return fmt.Errorf("resetting completed watch_progress positions: %w", err)
}
return nil
}
func migrateToV10(tx *sql.Tx) error {
if columnExists(tx, "watch_history", "watch_identity") {
return nil
}
if _, err := tx.Exec("ALTER TABLE watch_history ADD COLUMN watch_identity TEXT NOT NULL DEFAULT '{}'"); err != nil {
return fmt.Errorf("adding watch_history.watch_identity: %w", err)
}
return nil
}
func userVersion(db *sql.DB) (int, error) {
var version int
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
return 0, fmt.Errorf("querying sqlite user_version: %w", err)
}
return version, nil
}
func setUserVersion(db *sql.DB, version int) error {
if _, err := db.Exec(fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
return fmt.Errorf("setting sqlite user_version %d: %w", version, err)
}
return nil
}
func migrateToV3(tx *sql.Tx) error {
cols := []struct{ name, ddl string }{
{"last_file_id", "ALTER TABLE watch_progress ADD COLUMN last_file_id INTEGER"},
{"last_resolution", "ALTER TABLE watch_progress ADD COLUMN last_resolution TEXT"},
{"last_hdr", "ALTER TABLE watch_progress ADD COLUMN last_hdr BOOLEAN"},
{"last_codec_video", "ALTER TABLE watch_progress ADD COLUMN last_codec_video TEXT"},
}
for _, c := range cols {
if columnExists(tx, "watch_progress", c.name) {
continue
}
if _, err := tx.Exec(c.ddl); err != nil {
return fmt.Errorf("adding watch_progress.%s: %w", c.name, err)
}
}
return nil
}
func migrateToV8(tx *sql.Tx) error {
if columnExists(tx, "watch_progress", "last_edition_key") {
return nil
}
if _, err := tx.Exec("ALTER TABLE watch_progress ADD COLUMN last_edition_key TEXT"); err != nil {
return fmt.Errorf("adding watch_progress.last_edition_key: %w", err)
}
return nil
}
func columnExists(tx *sql.Tx, table, column string) bool {
var count int
err := tx.QueryRow("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&count)
return err == nil && count > 0
}
func migrateToV2(tx *sql.Tx) error {
if _, err := tx.Exec("ALTER TABLE profiles ADD COLUMN library_restrictions_enabled BOOLEAN DEFAULT false"); err != nil {
return fmt.Errorf("adding profiles.library_restrictions_enabled: %w", err)
}
if _, err := tx.Exec("ALTER TABLE profiles ADD COLUMN max_playback_quality TEXT DEFAULT ''"); err != nil {
return fmt.Errorf("adding profiles.max_playback_quality: %w", err)
}
if _, err := tx.Exec(`
CREATE TABLE IF NOT EXISTS profile_allowed_libraries (
profile_id TEXT NOT NULL,
library_id INTEGER NOT NULL,
PRIMARY KEY (profile_id, library_id)
)`); err != nil {
return fmt.Errorf("creating profile_allowed_libraries: %w", err)
}
if _, err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_profile_allowed_libraries_lookup
ON profile_allowed_libraries(profile_id)`); err != nil {
return fmt.Errorf("creating idx_profile_allowed_libraries_lookup: %w", err)
}
return nil
}
func migrateToV4(tx *sql.Tx) error {
cols := []struct{ name, ddl string }{
{"creator_profile_id", "ALTER TABLE personal_collections ADD COLUMN creator_profile_id TEXT NOT NULL DEFAULT ''"},
{"collection_type", "ALTER TABLE personal_collections ADD COLUMN collection_type TEXT NOT NULL DEFAULT 'manual'"},
{"is_shared", "ALTER TABLE personal_collections ADD COLUMN is_shared BOOLEAN DEFAULT false"},
{"query_definition", "ALTER TABLE personal_collections ADD COLUMN query_definition TEXT NOT NULL DEFAULT '{}'"},
{"sort_config", "ALTER TABLE personal_collections ADD COLUMN sort_config TEXT NOT NULL DEFAULT '{}'"},
}
for _, c := range cols {
if columnExists(tx, "personal_collections", c.name) {
continue
}
if _, err := tx.Exec(c.ddl); err != nil {
return fmt.Errorf("adding personal_collections.%s: %w", c.name, err)
}
}
if _, err := tx.Exec(`
UPDATE personal_collections
SET creator_profile_id = profile_id
WHERE creator_profile_id = ''
`); err != nil {
return fmt.Errorf("backfilling creator_profile_id: %w", err)
}
if _, err := tx.Exec(`
CREATE TABLE IF NOT EXISTS personal_collection_profiles (
collection_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
PRIMARY KEY (collection_id, profile_id)
)`); err != nil {
return fmt.Errorf("creating personal_collection_profiles: %w", err)
}
if _, err := tx.Exec(`
INSERT OR IGNORE INTO personal_collection_profiles (collection_id, profile_id)
SELECT id, profile_id
FROM personal_collections
`); err != nil {
return fmt.Errorf("backfilling personal_collection_profiles: %w", err)
}
if _, err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_personal_collection_profiles_lookup
ON personal_collection_profiles(profile_id, collection_id)`); err != nil {
return fmt.Errorf("creating idx_personal_collection_profiles_lookup: %w", err)
}
return nil
}
func migrateToV5(tx *sql.Tx) error {
// Rename collection_mode → collection_type. SQLite ALTER TABLE RENAME COLUMN
// is supported in SQLite ≥ 3.25.0.
if columnExists(tx, "personal_collections", "collection_mode") {
if _, err := tx.Exec("ALTER TABLE personal_collections RENAME COLUMN collection_mode TO collection_type"); err != nil {
return fmt.Errorf("renaming personal_collections.collection_mode → collection_type: %w", err)
}
}
return nil
}
func migrateToV6(tx *sql.Tx) error {
if _, err := tx.Exec(`
CREATE TABLE IF NOT EXISTS series_playback_preferences (
profile_id TEXT NOT NULL,
series_id TEXT NOT NULL,
resolution TEXT,
hdr BOOLEAN NOT NULL DEFAULT false,
codec_video TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, series_id)
)`); err != nil {
return fmt.Errorf("creating series_playback_preferences: %w", err)
}
return nil
}
func migrateToV7(tx *sql.Tx) error {
cols := []struct {
table string
name string
ddl string
}{
{
table: "audio_preferences",
name: "audio_track_signature",
ddl: "ALTER TABLE audio_preferences ADD COLUMN audio_track_signature TEXT NOT NULL DEFAULT '{}'",
},
{
table: "subtitle_preferences",
name: "subtitle_track_signature",
ddl: "ALTER TABLE subtitle_preferences ADD COLUMN subtitle_track_signature TEXT NOT NULL DEFAULT '{}'",
},
}
for _, c := range cols {
if columnExists(tx, c.table, c.name) {
continue
}
if _, err := tx.Exec(c.ddl); err != nil {
return fmt.Errorf("adding %s.%s: %w", c.table, c.name, err)
}
}
return nil
}