2026-07-25 12:59:56 +02:00
|
|
|
package store
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
|
|
|
|
"fmt"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// User is one geniusrun account, bound 1:1 to an OIDC subject. Every
|
|
|
|
|
// synced/classified dataset (profile, workout kinds, activities, sync
|
|
|
|
|
// state) is scoped to exactly one User -- see
|
|
|
|
|
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md.
|
|
|
|
|
type User struct {
|
|
|
|
|
ID int64
|
|
|
|
|
OIDCSub string
|
|
|
|
|
DisplayName string
|
|
|
|
|
CreatedAt string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key
|
2026-07-25 19:24:19 +02:00
|
|
|
// the session-resolution middleware ever uses.
|
2026-07-25 12:59:56 +02:00
|
|
|
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
|
|
|
|
|
var u User
|
|
|
|
|
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
|
|
|
|
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt)
|
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
|
return User{}, false, nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return User{}, false, fmt.Errorf("get user by sub: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return u, true, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:24:19 +02:00
|
|
|
// neverMatchRule is the placeholder every fresh install's rule-engine kinds
|
|
|
|
|
// start with -- every activity lands in needs_review until the user tunes
|
|
|
|
|
// real rules.
|
2026-07-25 12:59:56 +02:00
|
|
|
const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}`
|
|
|
|
|
|
|
|
|
|
// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for
|
|
|
|
|
// a fresh install, so a newly-provisioned user starts with the same
|
|
|
|
|
// taxonomy instead of an empty one.
|
|
|
|
|
var defaultWorkoutKindSeeds = []WorkoutKind{
|
|
|
|
|
{Name: "Easy", Color: "#22c55e", RuleJSON: neverMatchRule, Priority: 80, IsActive: true},
|
|
|
|
|
{Name: "Long", Color: "#3b82f6", RuleJSON: neverMatchRule, Priority: 70, IsActive: true},
|
|
|
|
|
{Name: "60' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 60, IsActive: true},
|
|
|
|
|
{Name: "30' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 50, IsActive: true},
|
|
|
|
|
{Name: "Tempo", Color: "#eab308", RuleJSON: neverMatchRule, Priority: 40, IsActive: true},
|
|
|
|
|
{Name: "Intervals", Color: "#ef4444", RuleJSON: neverMatchRule, Priority: 30, IsActive: true},
|
|
|
|
|
{Name: "MAS Test", Color: "#a855f7", RuleJSON: neverMatchRule, Priority: 20, IsActive: true},
|
|
|
|
|
{Name: "Race", Color: "#dc2626", RuleJSON: `{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}`, Priority: 10, IsActive: true},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProvisionUser creates a brand-new geniusrun account for an OIDC subject:
|
|
|
|
|
// the users row, a default profile (name defaulted to displayName), the 8
|
|
|
|
|
// default workout kinds (with their paired workout_type_paces rows), and an
|
|
|
|
|
// initial sync_state row -- all in one transaction, so a partially
|
|
|
|
|
// provisioned user is never observable.
|
|
|
|
|
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
|
|
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("begin provision user tx: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
|
|
|
|
}
|
|
|
|
|
userID, err := res.LastInsertId()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, k := range defaultWorkoutKindSeeds {
|
|
|
|
|
res, err := tx.ExecContext(ctx, `
|
|
|
|
|
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
|
|
|
|
|
VALUES (?,?,?,?,?,?,?)`,
|
|
|
|
|
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("seed workout kind %q for user %d: %w", k.Name, userID, err)
|
|
|
|
|
}
|
|
|
|
|
kindID, err := res.LastInsertId()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO workout_type_paces (workout_kind_id) VALUES (?)`, kindID); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("seed workout type pace for kind %d: %w", kindID, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sync_state (user_id, earliest_synced_date, backfill_complete) VALUES (?, NULL, 0)`, userID); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("create sync state for user %d: %w", userID, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return userID, tx.Commit()
|
|
|
|
|
}
|
2026-07-26 10:17:21 +02:00
|
|
|
|
|
|
|
|
// DeleteUser permanently deletes userID's account. Every row that belongs
|
|
|
|
|
// to it -- profile, workout kinds (and their paces), activities (and their
|
|
|
|
|
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
|
|
|
|
|
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
|
|
|
|
|
// single statement rather than per-table deletes. Irreversible; the API
|
|
|
|
|
// layer gates this behind a UI confirmation (see
|
|
|
|
|
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
|
|
|
|
|
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
|
|
|
|
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
|
|
|
|
|
return fmt.Errorf("delete user %d: %w", userID, err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|