Documentation
¶
Overview ¶
Package db owns the Rivolt Postgres connection pool, the shared migration runner, and a handful of identity helpers used by every store.
Identity model ¶
A Rivolt user is identified by a username — sourced from an OIDC IdP for any real deployment, or hard-wired to "local" for the legacy single-tenant docker-compose UX. The stable user_id is a deterministic UUIDv5 over the lowercased username, so the same login always resolves to the same tenant across restarts, exports and future federated installs.
There is no default user. Stores expect a concrete user_id from an authenticated request context; unauthenticated writes are a bug, not a fallback.
Runtime model
- One pgx pool per process. main opens it, hands *sql.DB to every store. Stores never dial Postgres themselves.
- pgx is used through the database/sql stdlib adapter (github.com/jackc/pgx/v5/stdlib) so store code keeps using the stdlib *sql.DB API. The only Postgres-specific wart is placeholder style — we keep `?` in SQL strings and rewrite to `$1,$2,…` via Rebind at call time.
Index ¶
- Constants
- Variables
- func ClaimOTAFetch(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, ...) (bool, error)
- func ClaimReauthNudge(ctx context.Context, d *sql.DB, userID uuid.UUID, cooldown time.Duration) (bool, error)
- func ClaimSetupNudge(ctx context.Context, d *sql.DB, userID uuid.UUID, attempt int) (bool, error)
- func ClearIdPDeleteFailure(ctx context.Context, d *sql.DB, username string) error
- func CountAdmins(ctx context.Context, d *sql.DB) (int, error)
- func CountUsers(ctx context.Context, d *sql.DB) (int, error)
- func CreateUser(ctx context.Context, d *sql.DB, username, email, displayName, role string) (uuid.UUID, error)
- func DeleteUser(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func DeleteUserAccount(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func EnsureReadonlyGrants(ctx context.Context, d *sql.DB, role string, logger *slog.Logger)
- func EnsureUser(ctx context.Context, d *sql.DB, username string) (uuid.UUID, error)
- func EnsureUserFull(ctx context.Context, d *sql.DB, username, email, displayName string) (uuid.UUID, error)
- func EnsureUserVerified(ctx context.Context, d *sql.DB, username, email, displayName string, ...) (uuid.UUID, error)
- func GetNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID) (bool, string, error)
- func GrantReadonly(ctx context.Context, d *sql.DB, role string) (bool, error)
- func IdPDeleteFailureUsername(ctx context.Context, d *sql.DB, id uuid.UUID) (string, error)
- func IsDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)
- func LatestApplied(ctx context.Context, d *sql.DB) (string, error)
- func LatestEmbedded() (string, error)
- func ListSubscribableVehicleIDs(ctx context.Context, d *sql.DB) ([]string, error)
- func ListUserRivianVehicleIDs(ctx context.Context, d *sql.DB, userID uuid.UUID) ([]string, error)
- func ListUsersWithRivianSession(ctx context.Context, db *sql.DB) ([]uuid.UUID, error)
- func ListVehicleOTAs(ctx context.Context, d *sql.DB, userID uuid.UUID) (map[string]json.RawMessage, error)
- func LookupUserEmail(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func LookupUsername(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func Migrate(ctx context.Context, db *sql.DB) error
- func OnboardingCompleted(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)
- func Open(ctx context.Context, dsn string) (*sql.DB, error)
- func OpenNoMigrate(ctx context.Context, dsn string) (*sql.DB, error)
- func OwnsRivianID(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID string) (bool, error)
- func RaiseNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string) (transitioned bool, err error)
- func RawUsernameByID(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func Rebind(query string) string
- func RecordIdPDeleteFailure(ctx context.Context, d *sql.DB, username, errMsg string) (uuid.UUID, error)
- func RequireTables(ctx context.Context, d *sql.DB, tables ...string) error
- func ResolveIdPDeleteFailure(ctx context.Context, d *sql.DB, id uuid.UUID) error
- func RoleFor(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func SetDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID, disabled bool) error
- func SetNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string) error
- func SetOnboardingCompleted(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func SetRole(ctx context.Context, d *sql.DB, uid uuid.UUID, role string) error
- func SetVehicleImageURL(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID, url string) error
- func SetVehicleOTA(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, ...) error
- func SetVehicleProfile(ctx context.Context, d *sql.DB, userID, vehicleID uuid.UUID, p VehicleProfile) error
- func TableExists(ctx context.Context, d *sql.DB, name string) (bool, error)
- func UpsertVehicleFromSync(ctx context.Context, d *sql.DB, userID uuid.UUID, v VehicleSync) error
- func UserExistsByUsername(ctx context.Context, d *sql.DB, username string) (bool, error)
- func UserIDFor(username string) uuid.UUID
- func UsernameExists(ctx context.Context, d *sql.DB, username string) (bool, error)
- func VehicleOwner(ctx context.Context, db *sql.DB, vehicleID uuid.UUID) (uuid.UUID, error)
- func VehicleOwnerByRivianID(ctx context.Context, db *sql.DB, rivianID string) (uuid.UUID, error)
- func WithUserScope(ctx context.Context, pool *sql.DB, userID uuid.UUID, ...) error
- type AdminSignupSnapshot
- type AdminUserDetail
- type AdminUserRow
- type AdminUserVehicle
- type DBTX
- type IdPDeleteFailure
- type ReauthNudge
- type SetupNudge
- type VehicleOwnership
- type VehicleProfile
- type VehicleResolver
- type VehicleResolverFactory
- type VehicleSummary
- type VehicleSync
Constants ¶
const MaxSetupNudges = 2
MaxSetupNudges caps the finish-setup emails per user: one a day after signup, one follow-up. More than that stops being a reminder.
Variables ¶
var ErrIdPFailureNotFound = errors.New("idp delete failure not found")
ErrIdPFailureNotFound is returned for an unknown failure id.
var ErrUserExists = fmt.Errorf("user already exists")
ErrUserExists is returned by CreateUser when the username is already taken. Distinguished from a generic insert error so the admin endpoint can surface a clean 409 instead of a 500.
Functions ¶
func ClaimOTAFetch ¶ added in v0.36.5
func ClaimOTAFetch(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, minInterval time.Duration) (bool, error)
ClaimOTAFetch records an attempt to fetch the vehicle's OTA details and reports whether this caller won it: no attempt in the last minInterval, across pods and restarts. It matches only the user's own vehicle.
func ClaimReauthNudge ¶ added in v0.25.0
func ClaimReauthNudge(ctx context.Context, d *sql.DB, userID uuid.UUID, cooldown time.Duration) (bool, error)
ClaimReauthNudge atomically records that this pod is about to send a stuck-session nudge, returning true only if the claim was won. The UPDATE's WHERE re-checks the due predicate, so when two replicas race on the same user Postgres' row lock lets exactly one flip needs_reauth_notified_at and send; the loser sees 0 rows and skips. Same rising-edge dedup shape as RaiseNeedsReauth, applied to the sweep instead of the classifier.
func ClaimSetupNudge ¶ added in v0.25.0
ClaimSetupNudge atomically records that finish-setup email number attempt is being sent, returning true only if the claim was won. The WHERE re-checks eligibility and pins the count to attempt-1, so a connect that landed since the candidate list was built, or a peer replica that got there first, makes the claim a no-op. Each attempt can be claimed once by construction.
func ClearIdPDeleteFailure ¶ added in v0.32.0
ClearIdPDeleteFailure drops any pending failure for username (an IdP deletion for it just succeeded).
func CountAdmins ¶
CountAdmins returns the number of users with role='admin'. Used by the admin endpoint to refuse demoting / deleting the last admin (which would lock the install out of /api/admin/*).
func CountUsers ¶ added in v0.21.0
CountUsers returns the total number of user rows. Used by the signup-cap gate in the OIDC callback and the admin UI's "N of M seats used" indicator.
func CreateUser ¶
func CreateUser(ctx context.Context, d *sql.DB, username, email, displayName, role string) (uuid.UUID, error)
CreateUser pre-provisions a user row from the admin endpoint. Auth is OIDC-only, so this does NOT issue a password — the row just reserves the deterministic UUIDv5 for the username and stamps role/email/display_name. When the user later signs in via OIDC with the same preferred_username, EnsureUserFull's upsert lands on this row and the pre-set role survives.
Returns ErrUserExists if a row with the same id is already present; callers should NOT silently turn that into an upsert, because the admin's intent is "create new", not "edit existing".
func DeleteUser ¶
DeleteUser removes a user row. ON DELETE CASCADE on every dependent table (vehicles, drives, charges, samples, user_settings, user_secrets, sessions, push_subscriptions, …) means this is sufficient to fully evict the tenant; the admin endpoint relies on that contract instead of a hand-rolled transactional sweep.
subscription_leases is the one exception: it's keyed by rivian_vehicle_id with no FK back to users, so the cascade misses it. A surviving lease is renewed forever by its owning pod, whose recorder then fails FK inserts against the now-deleted vehicle indefinitely. Drop the leases in the same transaction, while the vehicles rows still exist to resolve their ids, so the owning pod sees them vanish on its next renew and tears the subscription down.
charges_deleted_backup (repair-phantom-charges' undo copies) has no FK either: it must outlive the charges it backs up. A deleted user's copies go with them.
func DeleteUserAccount ¶ added in v0.32.0
DeleteUserAccount is the account-deletion path (self-service and admin): DeleteUser plus the signup_requests rows filed under the user's email or username, matched case-insensitively, in the same transaction. signup_requests has no FK to users (a request exists before its account does), so the cascade never reached it and a deleted person's email, message and signup token lingered.
Rollback paths (an IdP create that failed right after the rivolt row was written) must keep using DeleteUser: the request is still live there and the person retries with the same token.
func EnsureReadonlyGrants ¶ added in v0.31.3
EnsureReadonlyGrants is GrantReadonly with logging, for the boot and periodic callers. A missing role is logged once at info, not as an error: on preview or a fresh install the role may legitimately not exist.
func EnsureUser ¶
EnsureUser upserts the user row for a username and returns the stable UUID. Idempotent — safe to call on every boot (for the static operator credential) and on every sign-in (for future real auth) without coordination.
Returns an error if username is empty; the all-zero UUID would otherwise act as a silent multi-tenant footgun.
func EnsureUserFull ¶
func EnsureUserFull(ctx context.Context, d *sql.DB, username, email, displayName string) (uuid.UUID, error)
EnsureUserFull is EnsureUserVerified for callers that cannot vouch for the email: it never promotes the bootstrap admin.
func EnsureUserVerified ¶ added in v0.32.1
func EnsureUserVerified(ctx context.Context, d *sql.DB, username, email, displayName string, emailVerified bool) (uuid.UUID, error)
EnsureUserVerified is EnsureUser plus optional email and display_name columns. OIDC sign-in uses this so the user row reflects what the IdP knows (display_name in particular is what the UI surfaces in the account menu). Empty values are not written — a later sign-in that does carry them will fill the columns in, but a sign-in that doesn't (e.g. an IdP misconfigured to omit `name`) won't blank out a previously-good value.
Admin promotion: on first-ever insert (table empty) the 0015_users_role bootstrap trigger flips role to 'admin'. After that, RIVOLT_BOOTSTRAP_ADMIN_EMAIL — if set — promotes any user whose email matches it to admin on every sign-in. This makes it safe to set the env up-front: an IdP user with that email always becomes admin without any seed-time coordination.
func GetNeedsReauth ¶
GetNeedsReauth reads the current needs_reauth state for a user. Used by main.go at startup to prime the LiveClient's in-memory mirror so a restart doesn't silently let a locked-out user's background jobs hammer Rivian again.
A missing user row is treated as "not locked" rather than an error — the upstream call will fail on its own if the user really doesn't exist.
func GrantReadonly ¶ added in v0.31.3
GrantReadonly grants SELECT on the allowlist to role. It returns (false, nil) without doing anything when the role doesn't exist yet, so callers can retry later.
func IdPDeleteFailureUsername ¶ added in v0.32.0
IdPDeleteFailureUsername returns the username of a pending failure.
func IsDisabled ¶
IsDisabled reports whether the user has been marked disabled by an admin. Used by the auth middleware to refuse to honor a session cookie / OIDC sub for a disabled row, and by the admin endpoint to short-circuit role/delete operations against already-disabled users.
Returns (false, nil) for an unknown UUID — callers handling that path are already treating it as "no row, no privileges", so surfacing a sentinel error here would just bloat the call sites.
func LatestApplied ¶ added in v0.33.0
LatestApplied is the newest migration id recorded in the database ("" when the migrations table does not exist). Read-only.
func LatestEmbedded ¶ added in v0.33.0
LatestEmbedded is the id of the newest migration this binary ships.
func ListSubscribableVehicleIDs ¶ added in v0.26.0
ListSubscribableVehicleIDs returns every Rivian gateway vehicle-id whose owner currently has a stored Rivian session, i.e. a vehicle we have any business holding a subscription lease for.
This is the lease coordinator's authoritative set (SetAuthoritative): a lease whose vehicle is absent from it is reaped and unsubscribed. Gating on the *stored session* - not merely the vehicles row - is what makes disconnect take effect across replicas. Logout deletes the user_secrets 'rivian.session' row; on the next reconcile every pod sees the vehicle drop out of this set and tears the WS subscription down within one cycle. Without it, a logout on one pod left the lease-owning pod streaming from its in-memory session until the next restart happened to land on it (a user kept being recorded for days after disconnecting).
The session predicate is byte-identical to ListUsersWithRivianSession (the boot hydrate), so what gets a monitor at startup and what stays leased can never disagree. Synthetic electrafi-<hash> import rows are excluded - they're not real gateway VINs and never carry a session.
func ListUserRivianVehicleIDs ¶ added in v0.36.5
ListUserRivianVehicleIDs returns the user's rivian_vehicle_id values.
func ListUsersWithRivianSession ¶
ListUsersWithRivianSession returns every user_id that currently has a persisted rivian.session blob in user_secrets. The boot path uses this to hydrate every user's *LiveClient before the StateMonitor goroutines start, so a pod restart is invisible to the data plane regardless of which users are active.
The query reads ciphertext-presence only — it does NOT decrypt the blob, so it's safe to run before the secret-sealer is fully wired (e.g. during a future rotation window).
func ListVehicleOTAs ¶ added in v0.36.5
func ListVehicleOTAs(ctx context.Context, d *sql.DB, userID uuid.UUID) (map[string]json.RawMessage, error)
ListVehicleOTAs returns the stored OTA details by rivian_vehicle_id for the user's vehicles that have any.
func LookupUserEmail ¶ added in v0.24.5
LookupUserEmail returns the user's email address, or "" when the row has no email (admin-created accounts may lack one) or the UUID is unknown. Used to address transactional mail (re-auth notice) to the account owner.
func LookupUsername ¶
LookupUsername returns the display-friendly name for a user UUID, preferring display_name (set by OIDC sign-in from the IdP's `name` claim) and falling back to the bare username column. Returns the empty string for an unknown UUID; callers treat that as "no username available" rather than as an error.
func Migrate ¶
Migrate runs every unapplied migration in migrationFS against db. The migrations table is self-bootstrapped on first boot so fresh Postgres databases come up clean.
Multi-pod safety: two pods booting concurrently against the same database both call Migrate. We serialise them with a session-level pg_advisory_lock — the second caller blocks until the first finishes, then re-reads the migrations table and finds everything already applied. The lock is held on a dedicated connection so migrations themselves run on the pool unaffected, and is released (best-effort) on a background context so a cancelled ctx doesn't leak the lock for the rest of the conn's lifetime.
func OnboardingCompleted ¶
OnboardingCompleted reports whether the user has finished the first-run onboarding stepper. Returns true for legacy rows that predate the onboarding_completed column (migration 0026 back-fills them to TRUE). Returns true when d is nil so callers in no-DB mode never show the stepper.
func Open ¶
Open dials Postgres and returns a ready-to-use *sql.DB with every embedded migration applied. Safe to call once per process; subsequent callers should reuse the returned handle.
No user is seeded — EnsureUser is called by the auth layer on a successful login / token validation instead. That keeps Open free of any identity concerns so it stays trivially testable.
dsn accepts any DSN pgx understands — most commonly `postgres://user:pass@host:5432/dbname?sslmode=disable`.
func OpenNoMigrate ¶ added in v0.33.0
OpenNoMigrate connects and pings but never changes the schema. Repair and maintenance commands use it: a binary newer than the database must not apply pending migrations as a side effect.
func OwnsRivianID ¶
OwnsRivianID reports whether the given user owns a vehicle registered under the given Rivian gateway vehicle-id.
This is deliberately a plain SELECT (no upsert) so ownership probing can't be used to silently provision rows in another user's vehicles set — the write path goes through VehicleResolver.Resolve, which is user-scoped by construction.
Used by the HTTP ownership middleware as the single seam that decides whether /api/state/{vehicleID} and friends are allowed to touch Rivian upstream on behalf of the session user. False with nil error means "not yours" and the caller must return 404 (not 403) so enumerating vehicle-ids doesn't leak existence.
func RaiseNeedsReauth ¶ added in v0.24.5
func RaiseNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string) (transitioned bool, err error)
RaiseNeedsReauth sets the gate and reports whether THIS call flipped it from false to true (the rising edge). The UPDATE is guarded on `needs_reauth = FALSE`, so when two replicas classify the same user concurrently only the one that wins the transition gets transitioned = true. Callers use that to fire a one-time side effect (the re-auth email) without double-sending across pods. The reason/at columns are only written on the winning transition; a user already flagged keeps their original reason, which is fine — the flag, not the text, is load-bearing.
func RawUsernameByID ¶
RawUsernameByID returns the raw username column for a user by id, regardless of whether display_name is set. Used by the admin delete path to identify the row in the IdP backend (Kratos), which is keyed on the email/credential identifier. Returns the empty string for an unknown UUID.
func Rebind ¶
Rebind rewrites `?` placeholders into Postgres `$N` style. Safe to call on a query that has no placeholders. Ignores `?` inside single- or double-quoted string literals so we don't clobber data.
Kept cheap and inline because it runs on every query — ~a dozen instructions per placeholder. A sync.Map cache would complicate call sites for effectively zero wall-clock benefit on Rivolt's query volume.
func RecordIdPDeleteFailure ¶ added in v0.32.0
func RecordIdPDeleteFailure(ctx context.Context, d *sql.DB, username, errMsg string) (uuid.UUID, error)
RecordIdPDeleteFailure records (or, for a username already pending, updates) a failed IdP deletion and returns the row id, which is what logs should carry instead of the username (an email address).
func RequireTables ¶ added in v0.33.0
RequireTables verifies, read-only, that every named table exists, and returns an error that prints the database's and the binary's latest migration when one does not. Repair commands call it instead of migrating.
func ResolveIdPDeleteFailure ¶ added in v0.32.0
ResolveIdPDeleteFailure removes a failure once the IdP deletion has succeeded (nothing about the person is kept).
func RoleFor ¶
RoleFor returns the role for a user UUID ("user" or "admin"). Returns the empty string for an unknown UUID; callers treat that as "not authorized" rather than as an error.
func SetDisabled ¶
SetDisabled flips the disabled flag. The admin endpoint enforces the "can't disable the last admin / yourself" guards before calling this — keeping the SQL function dumb means tests can assert on the guard logic in isolation.
func SetNeedsReauth ¶
SetNeedsReauth persists the per-user re-auth gate to Postgres.
reason is stored verbatim in users.needs_reauth_reason for the UI to render; pass an empty string to clear the flag (the row is updated in one statement so the UI never sees a row with needs_reauth = true and an empty reason, or vice versa).
Best-effort by design: the caller is a LiveClient sink running on the hot path, and a Postgres blip shouldn't mask the original upstream error. Returned errors are for logging only.
func SetOnboardingCompleted ¶
SetOnboardingCompleted marks the user's onboarding stepper as finished. Idempotent — safe to call twice.
func SetRole ¶
SetRole updates the role on an existing user row. Used by the admin endpoint POST /api/admin/users/:id/role. Refuses to set anything other than 'user' / 'admin'; the CHECK constraint in 0015_users_role would catch it anyway, this just gives a friendlier error.
func SetVehicleImageURL ¶ added in v0.36.3
func SetVehicleImageURL(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID, url string) error
SetVehicleImageURL stores the hero image once the (slower) image lookup has landed, only touching a row whose value differs.
func SetVehicleOTA ¶ added in v0.36.5
func SetVehicleOTA(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, ota json.RawMessage) error
SetVehicleOTA stores the OTA details (a JSON object) for the user's vehicle, keeping every other metadata key.
func SetVehicleProfile ¶
func SetVehicleProfile(ctx context.Context, d *sql.DB, userID, vehicleID uuid.UUID, p VehicleProfile) error
SetVehicleProfile writes the profile struct into the vehicles.metadata JSONB at the "profile" key, preserving any other top-level metadata fields the row may hold.
func TableExists ¶ added in v0.33.0
TableExists reports whether a table exists in the public schema.
func UpsertVehicleFromSync ¶ added in v0.36.3
UpsertVehicleFromSync creates or refreshes the vehicles row for one vehicle the user owns. Existing values are kept when the sync has none (COALESCE), including trim and image_url, which the upsert used to drop.
func UserExistsByUsername ¶ added in v0.21.0
UserExistsByUsername reports whether a row already exists for the given identity key (post-normalisation). Cap-guard reads this to exempt returning users from the limit.
func UserIDFor ¶
UserIDFor returns the stable v5 UUID for a username. Case-folded and whitespace-trimmed so "Alice", "alice" and " alice " all land on the same user — usernames are display-y, IDs are identity.
Being deterministic means the same username produces the same UUID across every Rivolt install, which makes exports, imports and data migrations trivial: there is no lookup step.
func UsernameExists ¶ added in v0.32.0
UsernameExists reports whether a Rivolt account with this username (case-insensitive) exists.
func VehicleOwner ¶
VehicleOwner resolves an internal vehicles.id (UUID) back to its owning user_id. Used by the StateMonitor recorder to attribute frames to the right user even when a single pod monitors vehicles belonging to many users (post-PR-6 lease coordinator).
Returns (uuid.Nil, nil) when no row exists; callers should treat that as "stale subscription, drop the frame" rather than an error.
func VehicleOwnerByRivianID ¶
VehicleOwnerByRivianID is the Rivian-gateway-id-keyed sibling of VehicleOwner. The lease coordinator queries this when it acquires a lease so the per-user *LiveClient can be hydrated before the WS subscription starts.
func WithUserScope ¶
func WithUserScope( ctx context.Context, pool *sql.DB, userID uuid.UUID, fn func(ctx context.Context, tx DBTX) error, ) error
WithUserScope runs fn inside a transaction that has `app.user_id` set to userID at LOCAL scope. The transaction commits if fn returns nil, rolls back otherwise.
Safe to call with a nil pool (returns fn's zero error) to keep no-DB dev paths working.
Types ¶
type AdminSignupSnapshot ¶ added in v0.18.61
type AdminSignupSnapshot struct {
RequestedAt time.Time `json:"requested_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
AdminSignupSnapshot captures how this user came to exist when the signup_requests waitlist was the entry path. Nil for users created directly via the admin "create user" flow or seed scripts.
type AdminUserDetail ¶ added in v0.18.61
type AdminUserDetail struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
OnboardingDone bool `json:"onboarding_completed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Rivian state (DB-only — runtime in-memory state is per-pod and
// not authoritative; admins should trust what's persisted).
RivianConnected bool `json:"rivian_connected"`
RivianSessionAt *time.Time `json:"rivian_session_at,omitempty"`
NeedsReauth bool `json:"needs_reauth"`
NeedsReauthReason string `json:"needs_reauth_reason,omitempty"`
NeedsReauthAt *time.Time `json:"needs_reauth_at,omitempty"`
// Activity rollups.
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
ActiveSessions int `json:"active_sessions"`
DriveCount int `json:"drive_count"`
DriveMilesTotal float64 `json:"drive_miles_total"`
ChargeCount int `json:"charge_count"`
ChargeKWhTotal float64 `json:"charge_kwh_total"`
SampleCount int64 `json:"sample_count"`
OldestSampleAt *time.Time `json:"oldest_sample_at,omitempty"`
NewestSampleAt *time.Time `json:"newest_sample_at,omitempty"`
ImportCount int `json:"import_count"`
Vehicles []AdminUserVehicle `json:"vehicles"`
SignupRequest *AdminSignupSnapshot `json:"signup_request,omitempty"`
}
AdminUserDetail is the deep-dive bundle the admin drawer renders when the operator drills into a single user. Everything that helps answer "is this user healthy / why aren't they seeing data?" in one round-trip: identity, Rivian session age, per-vehicle telemetry freshness, lifetime totals, signup source, active session count.
func GetUserDetailForAdmin ¶ added in v0.18.61
GetUserDetailForAdmin assembles the per-user bundle as several short queries against the same connection. Multi-statement single query would be marginally faster but harder to reason about; with <100 users and an indexed (user_id) on every dependent table the fan-out cost is invisible.
type AdminUserRow ¶
type AdminUserRow struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
CreatedAt time.Time `json:"created_at"`
VehicleCount int `json:"vehicle_count"`
DriveCount int `json:"drive_count"`
ImportCount int `json:"import_count"`
RivianConnected bool `json:"rivian_connected"`
// NeedsReauth surfaces stuck Rivian sessions in the user table
// itself — these users' drives have stopped recording and they
// won't see the in-app banner unless they log in.
NeedsReauth bool `json:"needs_reauth"`
NeedsReauthAt *time.Time `json:"needs_reauth_at,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
}
AdminUserRow is the projection /api/admin/users serves. The activity counters (vehicles / drives / imports / rivian_connected / last_seen_at) let the admin see "did this user actually do anything?" without a per-user drill-down. Cheap scalar subqueries at our scale (<100 users); paginate if that ever changes.
func ListUsersForAdmin ¶
ListUsersForAdmin returns every user row, role-stamped and ordered by created_at so the admin page is stable across reloads. No paging — a self-hosted install will not have enough users to need it. If that ever stops being true, add LIMIT/OFFSET parameters.
type AdminUserVehicle ¶ added in v0.18.61
type AdminUserVehicle struct {
ID uuid.UUID `json:"id"`
RivianVehicleID string `json:"rivian_vehicle_id"`
VIN string `json:"vin,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Model string `json:"model,omitempty"`
ModelYear int `json:"model_year,omitempty"`
PackKWh float64 `json:"pack_kwh,omitempty"`
DriveCount int `json:"drive_count"`
ChargeCount int `json:"charge_count"`
LastSampleAt *time.Time `json:"last_sample_at,omitempty"`
}
AdminUserVehicle is one vehicle row enriched with the bits the admin drawer wants — telemetry recency and per-vehicle counts.
type DBTX ¶
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
}
DBTX is the subset of *sql.DB / *sql.Tx / *sql.Conn that every store uses. Having a named interface lets callers pass either the pool or a scoped tx without API churn.
type IdPDeleteFailure ¶ added in v0.32.0
type IdPDeleteFailure struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Error string `json:"error"`
Attempts int `json:"attempts"`
FailedAt time.Time `json:"failed_at"`
LastTried time.Time `json:"last_tried"`
// AccountExists is true when a Rivolt account with this username
// exists again (the person signed back in through the IdP identity
// we failed to delete). Retrying would lock them out, so the retry
// endpoint refuses it.
AccountExists bool `json:"account_exists"`
}
IdPDeleteFailure is a pending identity-provider deletion.
func ClaimDueIdPDeleteFailures ¶ added in v0.32.0
func ClaimDueIdPDeleteFailures(ctx context.Context, d *sql.DB, limit int) ([]IdPDeleteFailure, error)
ClaimDueIdPDeleteFailures returns up to limit pending failures whose backoff has elapsed (5 min × 2^(attempts-1), capped at 24 h; the exponent is capped at 9 first, so a row failing daily for months can't overflow the interval and break the query for every row) and stamps them as tried now, so another replica running the same loop skips them (FOR UPDATE SKIP LOCKED).
func ListIdPDeleteFailures ¶ added in v0.32.0
ListIdPDeleteFailures returns pending IdP deletions, oldest first.
type ReauthNudge ¶ added in v0.25.0
ReauthNudge is a user still stuck needing Rivian re-auth who is a candidate for a (re-)notification email.
func ListUsersDueForReauthNudge ¶ added in v0.25.0
func ListUsersDueForReauthNudge(ctx context.Context, d *sql.DB, cooldown time.Duration) ([]ReauthNudge, error)
ListUsersDueForReauthNudge returns users still flagged needs_reauth whose last nudge is either absent (NULL — includes everyone stranded before the email feature shipped) or older than cooldown. This is the candidate list only; the caller must ClaimReauthNudge each one before sending, which is what makes the sweep safe to run on every replica. The partial index users_needs_reauth_idx keeps this scan proportional to the flagged set, not the whole users table.
type SetupNudge ¶ added in v0.25.0
SetupNudge is a user who never connected a Rivian account and is due a finish-setup email. Attempt is which email this will be (1 or 2) so the sender can vary the copy.
func ListUsersDueForSetupNudge ¶ added in v0.25.0
func ListUsersDueForSetupNudge(ctx context.Context, d *sql.DB, minAge, followUpAfter time.Duration) ([]SetupNudge, error)
ListUsersDueForSetupNudge returns users who still have no vehicle (never completed a Rivian login - primeVehicles never ran, so no vehicles row), have a deliverable email, and are due their next finish-setup email:
- the first once the account is at least minAge old, and
- the follow-up once followUpAfter has passed since the first.
Onboarding state is deliberately not a filter: many users who stall never finish onboarding either (they close the tab on the connect step), and they need the reminder most.
Candidate list only; the caller must ClaimSetupNudge each one so the sweep is safe on every replica. Backed by users_setup_nudge_idx.
minAge exists so we don't email someone who is mid-flow: created the account and is about to type their Rivian password. A day's grace catches the ones who genuinely bounced.
type VehicleOwnership ¶
ListVehiclesWithOwners returns every (user_id, rivian_vehicle_id) tuple in the vehicles table. Used by the lease coordinator's vehicle source so the cluster's reconciliation set is keyed by user, not by rivian id alone — two users with the same rivian id (yes, this is possible: shared family account, dev/prod accidental reuse) are distinct lease subjects.
func ListVehiclesWithOwners ¶
ListVehiclesWithOwners returns every (user_id, rivian_vehicle_id) pair in the vehicles table. Empty rivian ids are skipped — the row is a placeholder waiting for first contact, not a subscription candidate.
type VehicleProfile ¶
type VehicleProfile struct {
TireType string `json:"tire_type,omitempty"`
WheelInches int `json:"wheel_inches,omitempty"`
Accessories []string `json:"accessories,omitempty"`
DefaultExtraLoadLb float64 `json:"default_extra_load_lb,omitempty"`
FrequentlyTows bool `json:"frequently_tows,omitempty"`
TirePlacardPSI float64 `json:"tire_placard_psi,omitempty"`
// NativeNACS overrides the model-year heuristic for whether the
// car has a native NACS port. nil = auto (model_year >= 2026 →
// native), true = native, false = CCS (needs Tesla adapter).
NativeNACS *bool `json:"native_nacs,omitempty"`
}
VehicleProfile is the user-editable per-vehicle context the efficiency analyzer factors into its breakdown. Persisted as the "profile" sub-key of the vehicles.metadata JSONB column; chosen over typed columns so future fields (e.g. windshield state, modified suspension) don't need migrations.
Field semantics:
- TireType: "all_season" | "all_terrain" | "winter" | "summer". Empty string means "unset" — the analyzer ignores it.
- WheelInches: 0 means unset. Rivian R1 ships 20/21/22.
- Accessories: free-form list ("roof_rack", "bike_rack", "rooftop_tent", "ski_box", etc.). Order is not significant.
- DefaultExtraLoadLb: persistent extra cargo carried on most drives (e.g. tools, child seat). The per-drive form on the efficiency card adds to this for trip-specific cargo.
- FrequentlyTows: hint that the vehicle is regularly used to tow. Doesn't affect the per-drive towing flag, which the analyzer reads independently.
- TirePlacardPSI: door-jamb placard pressure in psi. Optional (0 = unset). When supplied, the efficiency prompt cites the delta between current and placard so the model can confidently attribute a "Low tire pressure" factor instead of guessing the placard from generic priors. The user reads this off their door-jamb sticker once.
func GetVehicleProfile ¶
func GetVehicleProfile(ctx context.Context, d *sql.DB, userID, vehicleID uuid.UUID) (VehicleProfile, error)
GetVehicleProfile reads the "profile" sub-key from the given vehicle's metadata JSONB. Returns a zero VehicleProfile (not nil) when the key is missing or the row has no metadata — callers can treat the zero value as "unset" without nil-checking each field.
userID-scoped: a row owned by a different user returns sql.ErrNoRows to the caller, which the API layer translates to 404.
type VehicleResolver ¶
type VehicleResolver struct {
// contains filtered or unexported fields
}
VehicleResolver translates Rivian gateway vehicle-id strings (e.g. "01-242521064") into the internal vehicles.id UUID, creating the row on first sight. Results are cached in-process so the hot path (per-sample writes) doesn't hit the DB on every call.
Safe for concurrent use.
func NewVehicleResolver ¶
func NewVehicleResolver(d *sql.DB, userID uuid.UUID) *VehicleResolver
NewVehicleResolver builds a resolver scoped to a single user.
func (*VehicleResolver) Resolve ¶
Resolve returns the internal UUID for a Rivian vehicle-id string, upserting a bare vehicles row (user_id + rivian_vehicle_id) on first sight. Empty rivianID is an error — the stores should never be asked to write a row with no vehicle association.
type VehicleResolverFactory ¶
type VehicleResolverFactory struct {
// contains filtered or unexported fields
}
VehicleResolverFactory hands out per-user *VehicleResolver instances. Each user gets one resolver, cached for the process lifetime — the resolver itself caches (rivianID → internal UUID) in-memory so the recorder hot path doesn't round-trip Postgres on every sample, and per-user instances mean User A's resolver can never accidentally upsert a row attributed to User B.
Safe for concurrent use.
func NewVehicleResolverFactory ¶
func NewVehicleResolverFactory(db *sql.DB) *VehicleResolverFactory
NewVehicleResolverFactory builds a factory around the shared pool. The factory does not own the pool; main.go closes it.
func (*VehicleResolverFactory) DB ¶
func (f *VehicleResolverFactory) DB() *sql.DB
DB exposes the underlying pool for callers that need raw SQL against user-scoped tables (e.g. ownership probes). The factory retains pool ownership; do not close.
func (*VehicleResolverFactory) For ¶
func (f *VehicleResolverFactory) For(userID uuid.UUID) *VehicleResolver
For returns the resolver bound to userID, constructing one on first sight. Returns nil when userID is the zero UUID.
type VehicleSummary ¶
type VehicleSummary struct {
ID uuid.UUID `json:"id"`
RivianVehicleID string `json:"rivian_vehicle_id"`
VIN string `json:"vin,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Model string `json:"model,omitempty"`
ModelYear int `json:"model_year,omitempty"`
PackKWh float64 `json:"pack_kwh,omitempty"`
}
VehicleSummary is the per-user vehicle metadata exposed to /api/vehicles/owned (the import-picker source). It deliberately omits operational fields (created_at, updated_at) the SPA doesn't need — keeping the wire shape narrow makes the picker dropdown trivially serialisable.
func ListUserVehicles ¶
ListUserVehicles returns every real vehicle row owned by userID, excluding the legacy `electrafi-<hash>` synthetic rows left over from earlier importers. Synthetic rows linger in existing installs because their drives/charges/samples still reference them; we hide them from the picker so a fresh import can only land on a Rivian-linked vehicle.
type VehicleSync ¶ added in v0.36.3
type VehicleSync struct {
RivianID string
VIN string
Name string
Model string
ModelYear int
// Trim is Rivian's trimOption.optionId (e.g. "PKG-ADV", "LRG-DM-PRFM"):
// InferPackKWh and the pack-health "documented" figure key on it.
Trim string
// PackKWh is the InferPackKWh guess; it only seeds a null pack_kwh,
// because the vehicle-reported capacity may already be stored.
PackKWh float64
ImageURL string
}
VehicleSync is what a Rivian sync knows about one vehicle. Zero values mean "not known": they never overwrite a stored value.