Skip to main content

allowthem_saas/
control_db.rs

1use chrono::{DateTime, Utc};
2use serde::Serialize;
3use sqlx::{FromRow, Row, SqlitePool};
4use uuid::Uuid;
5
6use allowthem_core::error::AuthError;
7
8use crate::cache::TenantMeta;
9use crate::error::SaasError;
10use crate::tenants::{MemberId, Tenant, TenantId, TenantMember, TenantPlan, TenantStatus};
11
12#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
13pub struct TenantUsage {
14    pub period: String,
15    pub mau_count: i64,
16    pub limit_reached_at: Option<DateTime<Utc>>,
17    pub notified_at: Option<DateTime<Utc>>,
18}
19
20/// Role of a dashboard user inside a tenant. Mirrors `tenant_members.role`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, sqlx::Type)]
22#[sqlx(rename_all = "lowercase")]
23#[serde(rename_all = "lowercase")]
24pub enum TenantRole {
25    Owner,
26    Admin,
27    Viewer,
28}
29
30impl TenantRole {
31    pub fn is_owner(&self) -> bool {
32        matches!(self, TenantRole::Owner)
33    }
34
35    pub fn is_admin_or_owner(&self) -> bool {
36        matches!(self, TenantRole::Owner | TenantRole::Admin)
37    }
38
39    /// SQL-side spelling. Matches the `tenant_members.role` CHECK.
40    pub fn as_str(&self) -> &'static str {
41        match self {
42            TenantRole::Owner => "owner",
43            TenantRole::Admin => "admin",
44            TenantRole::Viewer => "viewer",
45        }
46    }
47}
48
49impl std::str::FromStr for TenantRole {
50    type Err = SaasError;
51
52    fn from_str(s: &str) -> Result<Self, Self::Err> {
53        match s {
54            "owner" => Ok(TenantRole::Owner),
55            "admin" => Ok(TenantRole::Admin),
56            "viewer" => Ok(TenantRole::Viewer),
57            other => Err(SaasError::InvalidRole(other.to_owned())),
58        }
59    }
60}
61
62pub struct ControlDb {
63    pool: SqlitePool,
64}
65
66impl ControlDb {
67    pub async fn new(pool: SqlitePool) -> Result<Self, AuthError> {
68        sqlx::migrate!("./migrations")
69            .run(&pool)
70            .await
71            .map_err(sqlx::Error::from)?;
72        Ok(Self { pool })
73    }
74
75    pub fn pool(&self) -> &SqlitePool {
76        &self.pool
77    }
78
79    pub async fn tenant_meta_by_slug(&self, slug: &str) -> Result<Option<TenantMeta>, SaasError> {
80        let row = sqlx::query("SELECT id, status, plan_id FROM tenants WHERE slug = ?1")
81            .bind(slug)
82            .fetch_optional(&self.pool)
83            .await?;
84
85        let Some(row) = row else { return Ok(None) };
86
87        let id_bytes: Vec<u8> = row.try_get("id")?;
88        let status: TenantStatus = row.try_get("status")?;
89        let plan_id: Vec<u8> = row.try_get("plan_id")?;
90        let id = Uuid::from_slice(&id_bytes).map_err(|_| SaasError::TenantNotFound)?;
91
92        Ok(Some(TenantMeta {
93            id: TenantId::from(id),
94            status,
95            plan_id,
96        }))
97    }
98
99    /// Returns the `(Tenant, role)` pairs for every tenant the email has
100    /// accepted membership in. Sorted by `t.name ASC`, with deleted tenants
101    /// excluded. Used by the dashboard workspace switcher.
102    pub async fn tenants_for_member(
103        &self,
104        email: &str,
105    ) -> Result<Vec<(Tenant, TenantRole)>, SaasError> {
106        let rows = sqlx::query(
107            "SELECT t.id, t.name, t.slug, t.owner_email, t.plan_id, t.status, \
108                    t.db_path, t.last_seen_at, t.created_at, t.updated_at, m.role \
109             FROM tenants t \
110             JOIN tenant_members m ON m.tenant_id = t.id \
111             WHERE m.email = ?1 AND m.accepted_at IS NOT NULL \
112               AND t.status != 'deleted' \
113             ORDER BY t.name ASC",
114        )
115        .bind(email)
116        .fetch_all(&self.pool)
117        .await?;
118
119        let mut out = Vec::with_capacity(rows.len());
120        for row in rows {
121            let tenant = Tenant::from_row(&row)?;
122            let role: TenantRole = row.try_get("role")?;
123            out.push((tenant, role));
124        }
125        Ok(out)
126    }
127
128    /// Returns the role the given email has on the tenant, or `None` if the
129    /// user is not an accepted member. Used by `RequireTenantMember` /
130    /// `RequireTenantAdmin` / `RequireTenantOwner`.
131    pub async fn member_role(
132        &self,
133        tenant_id: &TenantId,
134        email: &str,
135    ) -> Result<Option<TenantRole>, SaasError> {
136        let row: Option<(TenantRole,)> = sqlx::query_as(
137            "SELECT role FROM tenant_members \
138             WHERE tenant_id = ?1 AND email = ?2 AND accepted_at IS NOT NULL",
139        )
140        .bind(tenant_id.as_bytes())
141        .bind(email)
142        .fetch_optional(&self.pool)
143        .await?;
144        Ok(row.map(|(r,)| r))
145    }
146
147    pub async fn most_recently_seen_tenants(&self, count: i64) -> Result<Vec<TenantId>, SaasError> {
148        let rows = sqlx::query(
149            "SELECT id FROM tenants \
150             WHERE status = 'active' AND last_seen_at IS NOT NULL \
151             ORDER BY last_seen_at DESC LIMIT ?1",
152        )
153        .bind(count)
154        .fetch_all(&self.pool)
155        .await?;
156
157        let mut result = Vec::with_capacity(rows.len());
158        for row in rows {
159            let bytes: Vec<u8> = row.try_get("id")?;
160            match Uuid::from_slice(&bytes) {
161                Ok(uuid) => result.push(TenantId::from(uuid)),
162                Err(_) => {
163                    tracing::warn!("skipping tenant with undecodable UUID in most_recently_seen");
164                }
165            }
166        }
167        Ok(result)
168    }
169
170    pub async fn touch_last_seen(&self, tenant_id: &TenantId) -> Result<(), SaasError> {
171        sqlx::query("UPDATE tenants SET last_seen_at = datetime('now') WHERE id = ?1")
172            .bind(tenant_id.as_bytes())
173            .execute(&self.pool)
174            .await?;
175        Ok(())
176    }
177
178    pub async fn usage_for_tenant(
179        &self,
180        tenant_id: &TenantId,
181    ) -> Result<Vec<TenantUsage>, SaasError> {
182        let rows = sqlx::query_as::<_, TenantUsage>(
183            "SELECT period, mau_count, limit_reached_at, notified_at \
184             FROM tenant_usage \
185             WHERE tenant_id = ?1 \
186             ORDER BY period DESC",
187        )
188        .bind(tenant_id.as_bytes())
189        .fetch_all(&self.pool)
190        .await?;
191        Ok(rows)
192    }
193
194    /// Append an entry to `control_audit_events`.
195    ///
196    /// Call sites are fire-and-forget: log the error and continue. An
197    /// unlogged audit event is a nuisance, never a correctness bug.
198    /// `context` is serialised to a JSON string before persistence so the
199    /// callers can pass arbitrary `serde_json::Value` shapes.
200    pub async fn log_control_audit(
201        &self,
202        actor: &str,
203        action: &str,
204        tenant_id: Option<&TenantId>,
205        context: &serde_json::Value,
206    ) -> Result<(), SaasError> {
207        let id = Uuid::now_v7();
208        let context_str = context.to_string();
209        sqlx::query(
210            "INSERT INTO control_audit_events (id, actor, action, tenant_id, context) \
211             VALUES (?1, ?2, ?3, ?4, ?5)",
212        )
213        .bind(id.as_bytes().as_slice())
214        .bind(actor)
215        .bind(action)
216        .bind(tenant_id.map(|t| t.as_bytes()))
217        .bind(&context_str)
218        .execute(&self.pool)
219        .await?;
220        Ok(())
221    }
222}
223
224// ---------------------------------------------------------------------------
225// tenant_members CRUD — read paths and simple writes (invite, accept).
226// Last-owner-invariant writes (update_member_role, remove_member) live in
227// the next impl block so the SQL there gets focused review.
228// ---------------------------------------------------------------------------
229
230impl ControlDb {
231    /// All members of a tenant. Pending invites (`accepted_at IS NULL`)
232    /// surface first, then accepted rows ordered by `invited_at`.
233    pub async fn list_tenant_members(
234        &self,
235        tenant_id: &TenantId,
236    ) -> Result<Vec<TenantMember>, SaasError> {
237        let rows = sqlx::query_as::<_, TenantMember>(
238            "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
239                    invite_token_expires_at \
240             FROM tenant_members \
241             WHERE tenant_id = ?1 \
242             ORDER BY (accepted_at IS NULL) DESC, invited_at ASC",
243        )
244        .bind(tenant_id.as_bytes())
245        .fetch_all(&self.pool)
246        .await?;
247        Ok(rows)
248    }
249
250    pub async fn get_tenant_member(
251        &self,
252        member_id: &MemberId,
253    ) -> Result<Option<TenantMember>, SaasError> {
254        let row = sqlx::query_as::<_, TenantMember>(
255            "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
256                    invite_token_expires_at \
257             FROM tenant_members WHERE id = ?1",
258        )
259        .bind(member_id.as_bytes())
260        .fetch_optional(&self.pool)
261        .await?;
262        Ok(row)
263    }
264
265    /// Insert a pending member row. Returns the new [`MemberId`]. The
266    /// `(tenant_id, email)` UNIQUE constraint maps to
267    /// [`SaasError::MemberAlreadyExists`] on collision.
268    pub async fn invite_member(
269        &self,
270        tenant_id: &TenantId,
271        email: &str,
272        role: TenantRole,
273        token_hash: &[u8],
274        expires_at: i64,
275    ) -> Result<MemberId, SaasError> {
276        let id = MemberId::new();
277        let result = sqlx::query(
278            "INSERT INTO tenant_members \
279                 (id, tenant_id, email, role, invite_token_hash, invite_token_expires_at) \
280             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
281        )
282        .bind(id.as_bytes())
283        .bind(tenant_id.as_bytes())
284        .bind(email)
285        .bind(role.as_str())
286        .bind(token_hash)
287        .bind(expires_at)
288        .execute(&self.pool)
289        .await;
290        match result {
291            Ok(_) => Ok(id),
292            Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
293                Err(SaasError::MemberAlreadyExists)
294            }
295            Err(e) => Err(SaasError::Db(e)),
296        }
297    }
298
299    /// Look up a pending invite by `token_hash` (read-only — does not
300    /// consume the token). Used by the GET `/invite/{token}` page so the
301    /// invitee can preview the tenant + role before posting back.
302    pub async fn find_pending_invite_by_hash(
303        &self,
304        token_hash: &[u8],
305    ) -> Result<Option<TenantMember>, SaasError> {
306        let now_secs = chrono::Utc::now().timestamp();
307        let row = sqlx::query_as::<_, TenantMember>(
308            "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
309                    invite_token_expires_at \
310             FROM tenant_members \
311             WHERE invite_token_hash = ?1 \
312               AND accepted_at IS NULL \
313               AND (invite_token_expires_at IS NULL OR invite_token_expires_at > ?2)",
314        )
315        .bind(token_hash)
316        .bind(now_secs)
317        .fetch_optional(&self.pool)
318        .await?;
319        Ok(row)
320    }
321
322    /// Validate a pending invite by `token_hash`, mark it accepted, and
323    /// return the row as it was *before* the UPDATE so the caller has the
324    /// pre-clear `email` + `role`. Counterintuitive naming, but the
325    /// alternative (return the post-UPDATE row with NULL token columns)
326    /// is strictly worse for callers.
327    pub async fn accept_invite(&self, token_hash: &[u8]) -> Result<TenantMember, SaasError> {
328        let now_secs = chrono::Utc::now().timestamp();
329        let mut tx = self.pool.begin().await?;
330        let row = sqlx::query_as::<_, TenantMember>(
331            "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
332                    invite_token_expires_at \
333             FROM tenant_members \
334             WHERE invite_token_hash = ?1 \
335               AND accepted_at IS NULL \
336               AND (invite_token_expires_at IS NULL OR invite_token_expires_at > ?2)",
337        )
338        .bind(token_hash)
339        .bind(now_secs)
340        .fetch_optional(&mut *tx)
341        .await?
342        .ok_or(SaasError::InviteNotFoundOrExpired)?;
343
344        sqlx::query(
345            "UPDATE tenant_members \
346             SET accepted_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \
347                 invite_token_hash = NULL, \
348                 invite_token_expires_at = NULL \
349             WHERE id = ?1",
350        )
351        .bind(&row.id)
352        .execute(&mut *tx)
353        .await?;
354        tx.commit().await?;
355        Ok(row)
356    }
357}
358
359// ---------------------------------------------------------------------------
360// tenant_members CRUD — last-owner-invariant writes.
361//
362// The invariant: a tenant must always have at least one accepted owner.
363// We enforce it inside an explicit transaction (count owners, decide,
364// then mutate) rather than relying on a single-statement subquery,
365// which would be brittle to read and to test.
366// ---------------------------------------------------------------------------
367
368impl ControlDb {
369    /// Number of accepted owners for the given tenant.
370    pub async fn count_owners(&self, tenant_id: &TenantId) -> Result<i64, SaasError> {
371        let n: i64 = sqlx::query_scalar(
372            "SELECT COUNT(*) FROM tenant_members \
373             WHERE tenant_id = ?1 AND role = 'owner' AND accepted_at IS NOT NULL",
374        )
375        .bind(tenant_id.as_bytes())
376        .fetch_one(&self.pool)
377        .await?;
378        Ok(n)
379    }
380
381    /// Update a member's role. Rejects with [`SaasError::CannotDemoteLastOwner`]
382    /// if this would leave the tenant with zero accepted owners.
383    pub async fn update_member_role(
384        &self,
385        member_id: &MemberId,
386        new_role: TenantRole,
387    ) -> Result<(), SaasError> {
388        let mut tx = self.pool.begin().await?;
389        let target: Option<(Vec<u8>, String, Option<DateTime<Utc>>)> =
390            sqlx::query_as("SELECT tenant_id, role, accepted_at FROM tenant_members WHERE id = ?1")
391                .bind(member_id.as_bytes())
392                .fetch_optional(&mut *tx)
393                .await?;
394        let (tenant_blob, current_role, accepted_at) = target.ok_or(SaasError::MemberNotFound)?;
395
396        let demoting_owner = current_role == "owner" && !matches!(new_role, TenantRole::Owner);
397        if demoting_owner && accepted_at.is_some() {
398            let n: i64 = sqlx::query_scalar(
399                "SELECT COUNT(*) FROM tenant_members \
400                 WHERE tenant_id = ?1 AND role = 'owner' AND accepted_at IS NOT NULL",
401            )
402            .bind(&tenant_blob)
403            .fetch_one(&mut *tx)
404            .await?;
405            if n <= 1 {
406                return Err(SaasError::CannotDemoteLastOwner);
407            }
408        }
409
410        sqlx::query("UPDATE tenant_members SET role = ?1 WHERE id = ?2")
411            .bind(new_role.as_str())
412            .bind(member_id.as_bytes())
413            .execute(&mut *tx)
414            .await?;
415        tx.commit().await?;
416        Ok(())
417    }
418
419    /// Remove a member. Rejects with [`SaasError::CannotRemoveLastOwner`]
420    /// if removing this row would leave the tenant with zero accepted owners.
421    pub async fn remove_member(&self, member_id: &MemberId) -> Result<(), SaasError> {
422        let mut tx = self.pool.begin().await?;
423        let target: Option<(Vec<u8>, String, Option<DateTime<Utc>>)> =
424            sqlx::query_as("SELECT tenant_id, role, accepted_at FROM tenant_members WHERE id = ?1")
425                .bind(member_id.as_bytes())
426                .fetch_optional(&mut *tx)
427                .await?;
428        let (tenant_blob, role, accepted_at) = target.ok_or(SaasError::MemberNotFound)?;
429
430        if role == "owner" && accepted_at.is_some() {
431            let n: i64 = sqlx::query_scalar(
432                "SELECT COUNT(*) FROM tenant_members \
433                 WHERE tenant_id = ?1 AND role = 'owner' AND accepted_at IS NOT NULL",
434            )
435            .bind(&tenant_blob)
436            .fetch_one(&mut *tx)
437            .await?;
438            if n <= 1 {
439                return Err(SaasError::CannotRemoveLastOwner);
440            }
441        }
442
443        sqlx::query("DELETE FROM tenant_members WHERE id = ?1")
444            .bind(member_id.as_bytes())
445            .execute(&mut *tx)
446            .await?;
447        tx.commit().await?;
448        Ok(())
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Super-admin control-plane types (99c.6 Step 1).
454// ---------------------------------------------------------------------------
455
456/// One row returned by [`ControlDb::search_tenants`].
457#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
458pub struct TenantOverviewRow {
459    pub id: Vec<u8>,
460    pub name: String,
461    pub slug: String,
462    pub owner_email: String,
463    pub status: TenantStatus,
464    pub plan_name: String,
465    pub mau_count: i64,
466    pub created_at: DateTime<Utc>,
467    pub last_seen_at: Option<DateTime<Utc>>,
468}
469
470/// Column to sort [`ControlDb::search_tenants`] results by.
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
472pub enum TenantSortCol {
473    #[default]
474    Name,
475    Slug,
476    Status,
477    Plan,
478    Mau,
479    CreatedAt,
480    LastSeenAt,
481}
482
483impl TenantSortCol {
484    fn as_sql(&self) -> &'static str {
485        match self {
486            Self::Name => "t.name",
487            Self::Slug => "t.slug",
488            Self::Status => "t.status",
489            Self::Plan => "p.name",
490            Self::Mau => "COALESCE(tu.mau_count, 0)",
491            Self::CreatedAt => "t.created_at",
492            // NULLs sort last regardless of direction.
493            Self::LastSeenAt => "COALESCE(t.last_seen_at, '')",
494        }
495    }
496}
497
498/// Sort direction for [`ControlDb::search_tenants`].
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
500pub enum SortDir {
501    #[default]
502    Asc,
503    Desc,
504}
505
506impl SortDir {
507    fn as_sql(&self) -> &'static str {
508        match self {
509            Self::Asc => "ASC",
510            Self::Desc => "DESC",
511        }
512    }
513}
514
515/// Parameters for [`ControlDb::search_tenants`].
516pub struct SearchTenantsParams<'a> {
517    /// Optional search term matched against `name`, `slug`, and `owner_email`.
518    pub query: Option<&'a str>,
519    pub status: Option<TenantStatus>,
520    /// Filter by raw BLOB `plan_id`. Computed by the handler from a plan name.
521    pub plan_id: Option<&'a [u8]>,
522    /// `%Y-%m` period string (UTC) used for the MAU LEFT JOIN. Computed by the
523    /// handler; not embedded in the query to prevent SQL injection.
524    pub current_period: &'a str,
525    pub sort_col: TenantSortCol,
526    pub sort_dir: SortDir,
527    pub limit: u32,
528    pub offset: u32,
529}
530
531/// Result of [`ControlDb::search_tenants`].
532pub struct SearchTenantsResult {
533    pub rows: Vec<TenantOverviewRow>,
534    pub total: u32,
535}
536
537/// Per-period usage aggregate returned by [`ControlDb::aggregate_usage_for_period`]
538/// and [`ControlDb::aggregate_usage_history`].
539#[derive(Debug, Clone, Serialize)]
540pub struct PeriodAggregate {
541    pub period: String,
542    pub total_mau: i64,
543    pub active_tenants: i64,
544}
545
546// ---------------------------------------------------------------------------
547// Super-admin queries — counts, search, aggregates, mutations (99c.6 Step 1).
548// ---------------------------------------------------------------------------
549
550impl ControlDb {
551    /// Count tenants in a given status (active / suspended / deleted).
552    pub async fn count_tenants_by_status(&self, status: TenantStatus) -> Result<i64, SaasError> {
553        let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tenants WHERE status = ?1")
554            .bind(status)
555            .fetch_one(&self.pool)
556            .await?;
557        Ok(n)
558    }
559
560    /// Count tenants whose `created_at` is >= `since`.
561    pub async fn count_tenants_created_since(
562        &self,
563        since: DateTime<Utc>,
564    ) -> Result<i64, SaasError> {
565        let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tenants WHERE created_at >= ?1")
566            .bind(since)
567            .fetch_one(&self.pool)
568            .await?;
569        Ok(n)
570    }
571
572    /// Return `(plan_name, tenant_count)` pairs ordered by count descending.
573    ///
574    /// Deleted tenants are excluded. Plans with no tenants still appear with 0.
575    pub async fn count_tenants_grouped_by_plan(&self) -> Result<Vec<(String, i64)>, SaasError> {
576        let rows: Vec<(String, i64)> = sqlx::query_as(
577            "SELECT p.name, COUNT(t.id) AS count \
578             FROM tenant_plans p \
579             LEFT JOIN tenants t ON t.plan_id = p.id AND t.status != 'deleted' \
580             GROUP BY p.id, p.name \
581             ORDER BY count DESC",
582        )
583        .fetch_all(&self.pool)
584        .await?;
585        Ok(rows)
586    }
587
588    /// Paginated, sortable, filterable tenant search.
589    ///
590    /// Mirrors the `Box::leak`+dynamic-WHERE pattern from `Db::search_users`.
591    /// `plan_id` (BLOB) and `current_period` are bound separately from the
592    /// string-typed binds to preserve positional correctness.
593    pub async fn search_tenants(
594        &self,
595        p: SearchTenantsParams<'_>,
596    ) -> Result<SearchTenantsResult, SaasError> {
597        let mut where_clauses: Vec<String> = Vec::new();
598        let mut string_binds: Vec<String> = Vec::new();
599
600        if let Some(q) = p.query {
601            let trimmed = q.trim();
602            if !trimmed.is_empty() {
603                let esc = trimmed
604                    .replace('\\', "\\\\")
605                    .replace('%', "\\%")
606                    .replace('_', "\\_");
607                let pat = format!("%{esc}%");
608                where_clauses.push(
609                    "(t.name LIKE ? ESCAPE '\\' \
610                     OR t.slug LIKE ? ESCAPE '\\' \
611                     OR t.owner_email LIKE ? ESCAPE '\\')"
612                        .into(),
613                );
614                string_binds.push(pat.clone());
615                string_binds.push(pat.clone());
616                string_binds.push(pat);
617            }
618        }
619
620        if let Some(s) = p.status {
621            where_clauses.push("t.status = ?".into());
622            let s_str = match s {
623                TenantStatus::Active => "active",
624                TenantStatus::Suspended => "suspended",
625                TenantStatus::Deleted => "deleted",
626            };
627            string_binds.push(s_str.to_owned());
628        }
629
630        let has_plan_filter = p.plan_id.is_some();
631        if has_plan_filter {
632            where_clauses.push("t.plan_id = ?".into());
633        }
634
635        let where_sql = if where_clauses.is_empty() {
636            String::new()
637        } else {
638            format!("WHERE {}", where_clauses.join(" AND "))
639        };
640
641        // Count query — no JOINs needed; filter columns are all on `tenants`.
642        let count_sql: &'static str =
643            Box::leak(format!("SELECT COUNT(*) FROM tenants t {where_sql}").into_boxed_str());
644        let mut count_q = sqlx::query_scalar::<_, i64>(count_sql);
645        for v in &string_binds {
646            count_q = count_q.bind(v);
647        }
648        if let Some(pid) = p.plan_id {
649            count_q = count_q.bind(pid);
650        }
651        let total = count_q.fetch_one(&self.pool).await? as u32;
652
653        // Data query — LEFT JOIN usage for MAU; JOIN plans for plan name.
654        // sort_col/sort_dir come from an enum allowlist — no injection risk.
655        let sort_col_sql = p.sort_col.as_sql();
656        let sort_dir_sql = p.sort_dir.as_sql();
657        let data_sql: &'static str = Box::leak(
658            format!(
659                "SELECT t.id, t.name, t.slug, t.owner_email, t.status, \
660                 t.created_at, t.last_seen_at, \
661                 p.name AS plan_name, COALESCE(tu.mau_count, 0) AS mau_count \
662                 FROM tenants t \
663                 JOIN tenant_plans p ON p.id = t.plan_id \
664                 LEFT JOIN tenant_usage tu \
665                   ON tu.tenant_id = t.id AND tu.period = ? \
666                 {where_sql} \
667                 ORDER BY {sort_col_sql} {sort_dir_sql} \
668                 LIMIT ? OFFSET ?"
669            )
670            .into_boxed_str(),
671        );
672        let mut data_q = sqlx::query_as::<_, TenantOverviewRow>(data_sql).bind(p.current_period);
673        for v in &string_binds {
674            data_q = data_q.bind(v);
675        }
676        if let Some(pid) = p.plan_id {
677            data_q = data_q.bind(pid);
678        }
679        data_q = data_q.bind(p.limit).bind(p.offset);
680
681        let rows = data_q.fetch_all(&self.pool).await?;
682
683        Ok(SearchTenantsResult { rows, total })
684    }
685
686    /// Aggregate MAU + active-tenant count for a single period (e.g. `"2026-05"`).
687    ///
688    /// Returns zeros when no usage rows exist for the period.
689    pub async fn aggregate_usage_for_period(
690        &self,
691        period: &str,
692    ) -> Result<PeriodAggregate, SaasError> {
693        let (total_mau, active_tenants): (i64, i64) = sqlx::query_as(
694            "SELECT \
695               COALESCE(SUM(mau_count), 0) AS total_mau, \
696               COUNT(CASE WHEN mau_count > 0 THEN 1 END) AS active_tenants \
697             FROM tenant_usage \
698             WHERE period = ?1",
699        )
700        .bind(period)
701        .fetch_one(&self.pool)
702        .await?;
703
704        Ok(PeriodAggregate {
705            period: period.to_owned(),
706            total_mau,
707            active_tenants,
708        })
709    }
710
711    /// Return the `n_months` most-recent per-period aggregates, newest first.
712    pub async fn aggregate_usage_history(
713        &self,
714        n_months: u32,
715    ) -> Result<Vec<PeriodAggregate>, SaasError> {
716        let rows: Vec<(String, i64, i64)> = sqlx::query_as(
717            "SELECT period, \
718               SUM(mau_count) AS total_mau, \
719               COUNT(CASE WHEN mau_count > 0 THEN 1 END) AS active_tenants \
720             FROM tenant_usage \
721             GROUP BY period \
722             ORDER BY period DESC \
723             LIMIT ?1",
724        )
725        .bind(n_months)
726        .fetch_all(&self.pool)
727        .await?;
728
729        Ok(rows
730            .into_iter()
731            .map(|(period, total_mau, active_tenants)| PeriodAggregate {
732                period,
733                total_mau,
734                active_tenants,
735            })
736            .collect())
737    }
738
739    /// Count non-deleted tenants that have not been seen since `before`
740    /// (i.e. `last_seen_at` is NULL or earlier than the threshold).
741    pub async fn count_dormant_tenants(&self, before: DateTime<Utc>) -> Result<i64, SaasError> {
742        let n: i64 = sqlx::query_scalar(
743            "SELECT COUNT(*) FROM tenants \
744             WHERE status != 'deleted' \
745               AND (last_seen_at IS NULL OR last_seen_at < ?1)",
746        )
747        .bind(before)
748        .fetch_one(&self.pool)
749        .await?;
750        Ok(n)
751    }
752
753    /// Set a tenant's status. Bumps `updated_at`.
754    pub async fn set_tenant_status(
755        &self,
756        tenant_id: &TenantId,
757        status: TenantStatus,
758    ) -> Result<(), SaasError> {
759        sqlx::query(
760            "UPDATE tenants \
761             SET status = ?1, \
762                 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
763             WHERE id = ?2",
764        )
765        .bind(status)
766        .bind(tenant_id.as_bytes())
767        .execute(&self.pool)
768        .await?;
769        Ok(())
770    }
771
772    /// Set a tenant's plan. Bumps `updated_at`.
773    pub async fn set_tenant_plan(
774        &self,
775        tenant_id: &TenantId,
776        plan_id: &[u8],
777    ) -> Result<(), SaasError> {
778        sqlx::query(
779            "UPDATE tenants \
780             SET plan_id = ?1, \
781                 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
782             WHERE id = ?2",
783        )
784        .bind(plan_id)
785        .bind(tenant_id.as_bytes())
786        .execute(&self.pool)
787        .await?;
788        Ok(())
789    }
790
791    /// All available plans ordered by `price_cents ASC`.
792    pub async fn list_plans(&self) -> Result<Vec<TenantPlan>, SaasError> {
793        let rows = sqlx::query_as::<_, TenantPlan>(
794            "SELECT id, name, mau_limit, price_cents, features \
795             FROM tenant_plans \
796             ORDER BY price_cents ASC",
797        )
798        .fetch_all(&self.pool)
799        .await?;
800        Ok(rows)
801    }
802
803    /// Look up a plan by its raw BLOB id. Returns `None` if not found.
804    pub async fn get_plan_by_id(&self, plan_id: &[u8]) -> Result<Option<TenantPlan>, SaasError> {
805        let row = sqlx::query_as::<_, TenantPlan>(
806            "SELECT id, name, mau_limit, price_cents, features \
807             FROM tenant_plans WHERE id = ?1",
808        )
809        .bind(plan_id)
810        .fetch_optional(&self.pool)
811        .await?;
812        Ok(row)
813    }
814}
815
816// ---------------------------------------------------------------------------
817// tenant_domains CRUD
818// ---------------------------------------------------------------------------
819
820impl ControlDb {
821    /// Insert a new domain row for the tenant with `status = pending_verification`.
822    ///
823    /// Maps a UNIQUE violation on `domain` to [`SaasError::DomainAlreadyExists`].
824    pub async fn create_tenant_domain(
825        &self,
826        tenant_id: &crate::tenants::TenantId,
827        domain: &str,
828        dns_target: &str,
829    ) -> Result<crate::domains::TenantDomain, SaasError> {
830        use crate::domains::{DomainId, DomainStatus, TenantDomain};
831
832        let id = DomainId::new();
833        let result = sqlx::query(
834            "INSERT INTO tenant_domains \
835                 (id, tenant_id, domain, status, dns_target) \
836             VALUES (?1, ?2, ?3, ?4, ?5)",
837        )
838        .bind(id.as_bytes())
839        .bind(tenant_id.as_bytes())
840        .bind(domain)
841        .bind(DomainStatus::PendingVerification.as_str())
842        .bind(dns_target)
843        .execute(&self.pool)
844        .await;
845
846        match result {
847            Ok(_) => {}
848            Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
849                return Err(SaasError::DomainAlreadyExists);
850            }
851            Err(e) => return Err(SaasError::Db(e)),
852        }
853
854        let row = sqlx::query_as::<_, TenantDomain>(
855            "SELECT id, tenant_id, domain, status, dns_target, \
856                    verified_at, cert_expires_at, last_error, created_at, updated_at \
857             FROM tenant_domains WHERE id = ?1",
858        )
859        .bind(id.as_bytes())
860        .fetch_one(&self.pool)
861        .await?;
862        Ok(row)
863    }
864
865    /// All domains for the given tenant, ordered newest-first.
866    pub async fn list_tenant_domains(
867        &self,
868        tenant_id: &crate::tenants::TenantId,
869    ) -> Result<Vec<crate::domains::TenantDomain>, SaasError> {
870        use crate::domains::TenantDomain;
871
872        let rows = sqlx::query_as::<_, TenantDomain>(
873            "SELECT id, tenant_id, domain, status, dns_target, \
874                    verified_at, cert_expires_at, last_error, created_at, updated_at \
875             FROM tenant_domains \
876             WHERE tenant_id = ?1 \
877             ORDER BY created_at DESC, id DESC",
878        )
879        .bind(tenant_id.as_bytes())
880        .fetch_all(&self.pool)
881        .await?;
882        Ok(rows)
883    }
884
885    /// Fetch a single domain row only if it belongs to `tenant_id`.
886    ///
887    /// Returns `None` when the domain does not exist or belongs to another
888    /// tenant (caller should treat both as 404 at the API boundary).
889    pub async fn get_tenant_domain_scoped(
890        &self,
891        domain_id: &crate::domains::DomainId,
892        tenant_id: &crate::tenants::TenantId,
893    ) -> Result<Option<crate::domains::TenantDomain>, SaasError> {
894        use crate::domains::TenantDomain;
895
896        let row = sqlx::query_as::<_, TenantDomain>(
897            "SELECT id, tenant_id, domain, status, dns_target, \
898                    verified_at, cert_expires_at, last_error, created_at, updated_at \
899             FROM tenant_domains \
900             WHERE id = ?1 AND tenant_id = ?2",
901        )
902        .bind(domain_id.as_bytes())
903        .bind(tenant_id.as_bytes())
904        .fetch_optional(&self.pool)
905        .await?;
906        Ok(row)
907    }
908
909    /// Update the status of a domain row, bumping `updated_at`.
910    ///
911    /// - `verified_at` is set only when transitioning to `Verified`.
912    /// - `last_error` is cleared on `Verified` and set on `Failed`.
913    pub async fn set_tenant_domain_status(
914        &self,
915        domain_id: &crate::domains::DomainId,
916        status: crate::domains::DomainStatus,
917        verified_at: Option<chrono::DateTime<chrono::Utc>>,
918        last_error: Option<&str>,
919    ) -> Result<(), SaasError> {
920        sqlx::query(
921            "UPDATE tenant_domains \
922             SET status = ?1, \
923                 verified_at = ?2, \
924                 last_error = ?3, \
925                 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
926             WHERE id = ?4",
927        )
928        .bind(status.as_str())
929        .bind(verified_at)
930        .bind(last_error)
931        .bind(domain_id.as_bytes())
932        .execute(&self.pool)
933        .await?;
934        Ok(())
935    }
936
937    /// Delete a domain row only if it belongs to `tenant_id`.
938    ///
939    /// Returns `true` when a row was deleted, `false` when the domain did not
940    /// exist or belonged to another tenant.
941    pub async fn delete_tenant_domain_scoped(
942        &self,
943        domain_id: &crate::domains::DomainId,
944        tenant_id: &crate::tenants::TenantId,
945    ) -> Result<bool, SaasError> {
946        let result = sqlx::query("DELETE FROM tenant_domains WHERE id = ?1 AND tenant_id = ?2")
947            .bind(domain_id.as_bytes())
948            .bind(tenant_id.as_bytes())
949            .execute(&self.pool)
950            .await?;
951        Ok(result.rows_affected() > 0)
952    }
953
954    /// Find the tenant whose custom domain matches `domain` and whose status
955    /// is `verified` or `active`. Used by 38y.2's router fallback.
956    ///
957    /// Input is lowercased before binding (DNS is case-insensitive; storage
958    /// is always lowercase).
959    pub async fn tenant_by_custom_domain(
960        &self,
961        domain: &str,
962    ) -> Result<Option<crate::tenants::Tenant>, SaasError> {
963        use crate::tenants::Tenant;
964
965        let lower = domain.to_lowercase();
966        let row = sqlx::query_as::<_, Tenant>(
967            "SELECT t.id, t.name, t.slug, t.owner_email, t.plan_id, t.status, \
968                    t.db_path, t.last_seen_at, t.created_at, t.updated_at \
969             FROM tenants t \
970             JOIN tenant_domains d ON d.tenant_id = t.id \
971             WHERE d.domain = ?1 \
972               AND d.status IN ('verified', 'active')",
973        )
974        .bind(&lower)
975        .fetch_optional(&self.pool)
976        .await?;
977        Ok(row)
978    }
979
980    /// Rows eligible for the background verification sweep: those with status
981    /// `pending_verification` or `failed`, ordered oldest-first, capped at
982    /// `limit`.
983    pub async fn list_domains_for_sweep(
984        &self,
985        limit: i64,
986    ) -> Result<Vec<crate::domains::TenantDomain>, SaasError> {
987        use crate::domains::TenantDomain;
988
989        let rows = sqlx::query_as::<_, TenantDomain>(
990            "SELECT id, tenant_id, domain, status, dns_target, \
991                    verified_at, cert_expires_at, last_error, created_at, updated_at \
992             FROM tenant_domains \
993             WHERE status IN ('pending_verification', 'failed') \
994             ORDER BY updated_at ASC \
995             LIMIT ?1",
996        )
997        .bind(limit)
998        .fetch_all(&self.pool)
999        .await?;
1000        Ok(rows)
1001    }
1002}
1003
1004#[cfg(test)]
1005pub(crate) mod tests {
1006    use super::*;
1007    use sqlx::Row;
1008    use std::str::FromStr;
1009
1010    pub async fn test_pool() -> SqlitePool {
1011        let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
1012            .unwrap()
1013            .pragma("foreign_keys", "ON");
1014        SqlitePool::connect_with(opts).await.unwrap()
1015    }
1016
1017    #[tokio::test]
1018    async fn control_db_runs_migrations() {
1019        let pool = test_pool().await;
1020        let db = ControlDb::new(pool).await;
1021        assert!(db.is_ok());
1022    }
1023
1024    #[tokio::test]
1025    async fn log_control_audit_inserts_row() {
1026        let pool = test_pool().await;
1027        let db = ControlDb::new(pool).await.unwrap();
1028        db.log_control_audit(
1029            "owner@acme.com",
1030            "tenant.provisioned",
1031            None,
1032            &serde_json::json!({"slug": "acme"}),
1033        )
1034        .await
1035        .expect("log_control_audit");
1036
1037        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM control_audit_events")
1038            .fetch_one(db.pool())
1039            .await
1040            .unwrap();
1041        assert_eq!(count, 1);
1042
1043        let (actor, action, ctx): (String, String, String) =
1044            sqlx::query_as("SELECT actor, action, context FROM control_audit_events LIMIT 1")
1045                .fetch_one(db.pool())
1046                .await
1047                .unwrap();
1048        assert_eq!(actor, "owner@acme.com");
1049        assert_eq!(action, "tenant.provisioned");
1050        assert!(ctx.contains("acme"));
1051    }
1052
1053    #[tokio::test]
1054    async fn tenant_slug_unique() {
1055        let pool = test_pool().await;
1056        let db = ControlDb::new(pool).await.unwrap();
1057        let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1058            .fetch_one(db.pool())
1059            .await
1060            .unwrap();
1061        let plan_id: Vec<u8> = row.get("id");
1062        let id_a = uuid::Uuid::new_v4();
1063        let id_b = uuid::Uuid::new_v4();
1064        sqlx::query(
1065            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1066             VALUES (?, 'Acme', 'acme', 'a@a.com', ?, 'active', 'acme.db')",
1067        )
1068        .bind(id_a.as_bytes().as_ref())
1069        .bind(&plan_id)
1070        .execute(db.pool())
1071        .await
1072        .unwrap();
1073        let res = sqlx::query(
1074            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1075             VALUES (?, 'Acme 2', 'acme', 'b@b.com', ?, 'active', 'acme2.db')",
1076        )
1077        .bind(id_b.as_bytes().as_ref())
1078        .bind(&plan_id)
1079        .execute(db.pool())
1080        .await;
1081        assert!(res.is_err(), "duplicate slug should be rejected");
1082    }
1083
1084    #[tokio::test]
1085    async fn tenant_status_check_rejects_invalid() {
1086        let pool = test_pool().await;
1087        let db = ControlDb::new(pool).await.unwrap();
1088        let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1089            .fetch_one(db.pool())
1090            .await
1091            .unwrap();
1092        let plan_id: Vec<u8> = row.get("id");
1093        let id = uuid::Uuid::new_v4();
1094        let res = sqlx::query(
1095            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1096             VALUES (?, 'Bad', 'bad-status', 'c@c.com', ?, 'banned', 'bad.db')",
1097        )
1098        .bind(id.as_bytes().as_ref())
1099        .bind(&plan_id)
1100        .execute(db.pool())
1101        .await;
1102        assert!(res.is_err(), "invalid status should be rejected by CHECK");
1103    }
1104
1105    #[tokio::test]
1106    async fn member_role_check_rejects_invalid() {
1107        let pool = test_pool().await;
1108        let db = ControlDb::new(pool).await.unwrap();
1109        let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1110            .fetch_one(db.pool())
1111            .await
1112            .unwrap();
1113        let plan_id: Vec<u8> = row.get("id");
1114        let tenant_id = uuid::Uuid::new_v4();
1115        sqlx::query(
1116            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1117             VALUES (?, 'Role Test', 'role-test', 'd@d.com', ?, 'active', 'rtest.db')",
1118        )
1119        .bind(tenant_id.as_bytes().as_ref())
1120        .bind(&plan_id)
1121        .execute(db.pool())
1122        .await
1123        .unwrap();
1124        let member_id = uuid::Uuid::new_v4();
1125        let res = sqlx::query(
1126            "INSERT INTO tenant_members (id, tenant_id, email, role) \
1127             VALUES (?, ?, 'e@e.com', 'superuser')",
1128        )
1129        .bind(member_id.as_bytes().as_ref())
1130        .bind(tenant_id.as_bytes().as_ref())
1131        .execute(db.pool())
1132        .await;
1133        assert!(res.is_err(), "invalid role should be rejected by CHECK");
1134    }
1135
1136    #[tokio::test]
1137    async fn usage_for_tenant_returns_records() {
1138        let pool = test_pool().await;
1139        let db = ControlDb::new(pool).await.unwrap();
1140        let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1141            .fetch_one(db.pool())
1142            .await
1143            .unwrap();
1144        let plan_id: Vec<u8> = row.get("id");
1145        let tid = uuid::Uuid::new_v4();
1146        sqlx::query(
1147            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1148             VALUES (?, 'U', 'usagetest', 'u@u.com', ?, 'active', 'u.db')",
1149        )
1150        .bind(tid.as_bytes().as_ref())
1151        .bind(&plan_id)
1152        .execute(db.pool())
1153        .await
1154        .unwrap();
1155        let uid = uuid::Uuid::new_v4();
1156        sqlx::query(
1157            "INSERT INTO tenant_usage (id, tenant_id, period, mau_count) \
1158             VALUES (?, ?, '2026-04', 42)",
1159        )
1160        .bind(uid.as_bytes().as_ref())
1161        .bind(tid.as_bytes().as_ref())
1162        .execute(db.pool())
1163        .await
1164        .unwrap();
1165        let tenant_id = TenantId::from(tid);
1166        let usage = db.usage_for_tenant(&tenant_id).await.unwrap();
1167        assert_eq!(usage.len(), 1);
1168        assert_eq!(usage[0].period, "2026-04");
1169        assert_eq!(usage[0].mau_count, 42);
1170        assert!(usage[0].limit_reached_at.is_none());
1171    }
1172
1173    #[tokio::test]
1174    async fn api_key_hash_unique() {
1175        let pool = test_pool().await;
1176        let db = ControlDb::new(pool).await.unwrap();
1177        let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1178            .fetch_one(db.pool())
1179            .await
1180            .unwrap();
1181        let plan_id: Vec<u8> = row.get("id");
1182        let tenant_id = uuid::Uuid::new_v4();
1183        sqlx::query(
1184            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1185             VALUES (?, 'Key Test', 'key-test', 'f@f.com', ?, 'active', 'ktest.db')",
1186        )
1187        .bind(tenant_id.as_bytes().as_ref())
1188        .bind(&plan_id)
1189        .execute(db.pool())
1190        .await
1191        .unwrap();
1192        let hash = vec![0u8; 32];
1193        let key_id_a = uuid::Uuid::new_v4();
1194        let key_id_b = uuid::Uuid::new_v4();
1195        sqlx::query(
1196            "INSERT INTO tenant_api_keys (id, tenant_id, name, key_hash, scope) \
1197             VALUES (?, ?, 'key-a', ?, '[]')",
1198        )
1199        .bind(key_id_a.as_bytes().as_ref())
1200        .bind(tenant_id.as_bytes().as_ref())
1201        .bind(&hash)
1202        .execute(db.pool())
1203        .await
1204        .unwrap();
1205        let res = sqlx::query(
1206            "INSERT INTO tenant_api_keys (id, tenant_id, name, key_hash, scope) \
1207             VALUES (?, ?, 'key-b', ?, '[]')",
1208        )
1209        .bind(key_id_b.as_bytes().as_ref())
1210        .bind(tenant_id.as_bytes().as_ref())
1211        .bind(&hash)
1212        .execute(db.pool())
1213        .await;
1214        assert!(res.is_err(), "duplicate key_hash should be rejected");
1215    }
1216
1217    /// Helper: insert a tenant row with the given slug + status; return its `TenantId`.
1218    async fn seed_tenant_with_status(db: &ControlDb, slug: &str, status: &str) -> TenantId {
1219        let plan_id: Vec<u8> = sqlx::query_scalar("SELECT id FROM tenant_plans LIMIT 1")
1220            .fetch_one(db.pool())
1221            .await
1222            .unwrap();
1223        let tid = uuid::Uuid::new_v4();
1224        let db_path = format!("{slug}.db");
1225        sqlx::query(
1226            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1227             VALUES (?, ?, ?, ?, ?, ?, ?)",
1228        )
1229        .bind(tid.as_bytes().as_ref())
1230        .bind(slug)
1231        .bind(slug)
1232        .bind(format!("owner-{slug}@example.com"))
1233        .bind(&plan_id)
1234        .bind(status)
1235        .bind(&db_path)
1236        .execute(db.pool())
1237        .await
1238        .unwrap();
1239        TenantId::from(tid)
1240    }
1241
1242    async fn seed_tenant(db: &ControlDb, slug: &str) -> TenantId {
1243        seed_tenant_with_status(db, slug, "active").await
1244    }
1245
1246    async fn insert_member(
1247        db: &ControlDb,
1248        tenant_id: &TenantId,
1249        email: &str,
1250        role: &str,
1251        accepted: bool,
1252    ) {
1253        let id = uuid::Uuid::new_v4();
1254        if accepted {
1255            sqlx::query(
1256                "INSERT INTO tenant_members (id, tenant_id, email, role, accepted_at) \
1257                 VALUES (?, ?, ?, ?, datetime('now'))",
1258            )
1259            .bind(id.as_bytes().as_ref())
1260            .bind(tenant_id.as_bytes())
1261            .bind(email)
1262            .bind(role)
1263            .execute(db.pool())
1264            .await
1265            .unwrap();
1266        } else {
1267            sqlx::query(
1268                "INSERT INTO tenant_members (id, tenant_id, email, role) \
1269                 VALUES (?, ?, ?, ?)",
1270            )
1271            .bind(id.as_bytes().as_ref())
1272            .bind(tenant_id.as_bytes())
1273            .bind(email)
1274            .bind(role)
1275            .execute(db.pool())
1276            .await
1277            .unwrap();
1278        }
1279    }
1280
1281    #[tokio::test]
1282    async fn member_role_returns_role_for_accepted_member() {
1283        let db = ControlDb::new(test_pool().await).await.unwrap();
1284        let tid = seed_tenant(&db, "acme").await;
1285        insert_member(&db, &tid, "alice@x.com", "admin", true).await;
1286
1287        let role = db.member_role(&tid, "alice@x.com").await.unwrap();
1288        assert_eq!(role, Some(TenantRole::Admin));
1289    }
1290
1291    #[tokio::test]
1292    async fn member_role_ignores_unaccepted_invite() {
1293        let db = ControlDb::new(test_pool().await).await.unwrap();
1294        let tid = seed_tenant(&db, "acme").await;
1295        insert_member(&db, &tid, "bob@x.com", "viewer", false).await;
1296
1297        let role = db.member_role(&tid, "bob@x.com").await.unwrap();
1298        assert_eq!(role, None);
1299    }
1300
1301    #[tokio::test]
1302    async fn member_role_returns_none_for_non_member() {
1303        let db = ControlDb::new(test_pool().await).await.unwrap();
1304        let tid = seed_tenant(&db, "acme").await;
1305
1306        let role = db.member_role(&tid, "stranger@x.com").await.unwrap();
1307        assert_eq!(role, None);
1308    }
1309
1310    #[tokio::test]
1311    async fn tenants_for_member_returns_pairs_sorted_by_name() {
1312        let db = ControlDb::new(test_pool().await).await.unwrap();
1313        let zid = seed_tenant(&db, "zulu").await;
1314        let aid = seed_tenant(&db, "alpha").await;
1315        insert_member(&db, &aid, "x@y.com", "owner", true).await;
1316        insert_member(&db, &zid, "x@y.com", "admin", true).await;
1317
1318        let pairs = db.tenants_for_member("x@y.com").await.unwrap();
1319        assert_eq!(pairs.len(), 2);
1320        assert_eq!(pairs[0].0.slug, "alpha");
1321        assert_eq!(pairs[0].1, TenantRole::Owner);
1322        assert_eq!(pairs[1].0.slug, "zulu");
1323        assert_eq!(pairs[1].1, TenantRole::Admin);
1324    }
1325
1326    #[tokio::test]
1327    async fn tenants_for_member_excludes_deleted_tenants() {
1328        let db = ControlDb::new(test_pool().await).await.unwrap();
1329        let tid = seed_tenant_with_status(&db, "deadco", "deleted").await;
1330        insert_member(&db, &tid, "x@y.com", "owner", true).await;
1331
1332        let pairs = db.tenants_for_member("x@y.com").await.unwrap();
1333        assert!(pairs.is_empty());
1334    }
1335
1336    #[tokio::test]
1337    async fn tenants_for_member_excludes_unaccepted_invites() {
1338        let db = ControlDb::new(test_pool().await).await.unwrap();
1339        let tid = seed_tenant(&db, "acme").await;
1340        insert_member(&db, &tid, "pending@x.com", "viewer", false).await;
1341
1342        let pairs = db.tenants_for_member("pending@x.com").await.unwrap();
1343        assert!(pairs.is_empty());
1344    }
1345
1346    // ----- 99c.5 Step 3: tenant_members CRUD -----
1347
1348    fn token_hash(seed: u8) -> Vec<u8> {
1349        vec![seed; 32]
1350    }
1351
1352    fn future_ts() -> i64 {
1353        chrono::Utc::now().timestamp() + 60 * 60 * 24
1354    }
1355
1356    #[tokio::test]
1357    async fn list_tenant_members_returns_pending_first() {
1358        let db = ControlDb::new(test_pool().await).await.unwrap();
1359        let tid = seed_tenant(&db, "listtest").await;
1360        insert_member(&db, &tid, "owner@x.com", "owner", true).await;
1361        db.invite_member(
1362            &tid,
1363            "pending@x.com",
1364            TenantRole::Admin,
1365            &token_hash(1),
1366            future_ts(),
1367        )
1368        .await
1369        .unwrap();
1370
1371        let members = db.list_tenant_members(&tid).await.unwrap();
1372        assert_eq!(members.len(), 2);
1373        assert_eq!(members[0].email, "pending@x.com");
1374        assert!(members[0].accepted_at.is_none());
1375        assert_eq!(members[1].email, "owner@x.com");
1376        assert!(members[1].accepted_at.is_some());
1377    }
1378
1379    #[tokio::test]
1380    async fn invite_member_unique_collision_returns_error() {
1381        let db = ControlDb::new(test_pool().await).await.unwrap();
1382        let tid = seed_tenant(&db, "collide").await;
1383        db.invite_member(
1384            &tid,
1385            "dup@x.com",
1386            TenantRole::Viewer,
1387            &token_hash(2),
1388            future_ts(),
1389        )
1390        .await
1391        .unwrap();
1392        let err = db
1393            .invite_member(
1394                &tid,
1395                "dup@x.com",
1396                TenantRole::Viewer,
1397                &token_hash(3),
1398                future_ts(),
1399            )
1400            .await
1401            .expect_err("second invite must collide");
1402        assert!(matches!(err, SaasError::MemberAlreadyExists));
1403    }
1404
1405    #[tokio::test]
1406    async fn accept_invite_happy_path_clears_token() {
1407        let db = ControlDb::new(test_pool().await).await.unwrap();
1408        let tid = seed_tenant(&db, "happy").await;
1409        let hash = token_hash(4);
1410        db.invite_member(&tid, "joiner@x.com", TenantRole::Admin, &hash, future_ts())
1411            .await
1412            .unwrap();
1413
1414        let row = db.accept_invite(&hash).await.unwrap();
1415        assert_eq!(row.email, "joiner@x.com");
1416        assert!(
1417            row.accepted_at.is_none(),
1418            "pre-update row carries the prior NULL accepted_at"
1419        );
1420
1421        let mid = row.id_as_member_id().unwrap();
1422        let after = db.get_tenant_member(&mid).await.unwrap().unwrap();
1423        assert!(after.accepted_at.is_some());
1424        assert!(after.invite_token_expires_at.is_none());
1425    }
1426
1427    #[tokio::test]
1428    async fn accept_invite_expired_returns_error() {
1429        let db = ControlDb::new(test_pool().await).await.unwrap();
1430        let tid = seed_tenant(&db, "expired").await;
1431        let hash = token_hash(5);
1432        let past = chrono::Utc::now().timestamp() - 60;
1433        db.invite_member(&tid, "stale@x.com", TenantRole::Viewer, &hash, past)
1434            .await
1435            .unwrap();
1436        let err = db.accept_invite(&hash).await.expect_err("expired");
1437        assert!(matches!(err, SaasError::InviteNotFoundOrExpired));
1438    }
1439
1440    #[tokio::test]
1441    async fn accept_invite_already_accepted_returns_error() {
1442        let db = ControlDb::new(test_pool().await).await.unwrap();
1443        let tid = seed_tenant(&db, "twice").await;
1444        let hash = token_hash(6);
1445        db.invite_member(&tid, "once@x.com", TenantRole::Admin, &hash, future_ts())
1446            .await
1447            .unwrap();
1448        db.accept_invite(&hash).await.unwrap();
1449        let err = db.accept_invite(&hash).await.expect_err("re-accept");
1450        assert!(matches!(err, SaasError::InviteNotFoundOrExpired));
1451    }
1452
1453    #[tokio::test]
1454    async fn find_pending_invite_skips_expired_and_accepted() {
1455        let db = ControlDb::new(test_pool().await).await.unwrap();
1456        let tid = seed_tenant(&db, "find").await;
1457        let live = token_hash(7);
1458        db.invite_member(&tid, "live@x.com", TenantRole::Viewer, &live, future_ts())
1459            .await
1460            .unwrap();
1461        let found = db.find_pending_invite_by_hash(&live).await.unwrap();
1462        assert!(found.is_some());
1463
1464        db.accept_invite(&live).await.unwrap();
1465        let after = db.find_pending_invite_by_hash(&live).await.unwrap();
1466        assert!(after.is_none());
1467    }
1468
1469    // ----- 99c.5 Step 4: last-owner invariant -----
1470
1471    /// Insert an accepted member and return the typed [`MemberId`] so
1472    /// last-owner tests can target the row directly.
1473    async fn insert_member_typed(
1474        db: &ControlDb,
1475        tenant_id: &TenantId,
1476        email: &str,
1477        role: &str,
1478    ) -> MemberId {
1479        let mid = MemberId::new();
1480        sqlx::query(
1481            "INSERT INTO tenant_members (id, tenant_id, email, role, accepted_at) \
1482             VALUES (?, ?, ?, ?, datetime('now'))",
1483        )
1484        .bind(mid.as_bytes())
1485        .bind(tenant_id.as_bytes())
1486        .bind(email)
1487        .bind(role)
1488        .execute(db.pool())
1489        .await
1490        .unwrap();
1491        mid
1492    }
1493
1494    #[tokio::test]
1495    async fn count_owners_counts_only_accepted_owners() {
1496        let db = ControlDb::new(test_pool().await).await.unwrap();
1497        let tid = seed_tenant(&db, "count").await;
1498        insert_member_typed(&db, &tid, "owner@x.com", "owner").await;
1499        insert_member_typed(&db, &tid, "admin@x.com", "admin").await;
1500        // Pending owner — must NOT count.
1501        db.invite_member(
1502            &tid,
1503            "pending@x.com",
1504            TenantRole::Owner,
1505            &token_hash(8),
1506            future_ts(),
1507        )
1508        .await
1509        .unwrap();
1510
1511        assert_eq!(db.count_owners(&tid).await.unwrap(), 1);
1512    }
1513
1514    #[tokio::test]
1515    async fn update_member_role_demote_last_owner_fails() {
1516        let db = ControlDb::new(test_pool().await).await.unwrap();
1517        let tid = seed_tenant(&db, "demote-last").await;
1518        let owner = insert_member_typed(&db, &tid, "lone@x.com", "owner").await;
1519
1520        let err = db
1521            .update_member_role(&owner, TenantRole::Admin)
1522            .await
1523            .expect_err("demote sole owner");
1524        assert!(matches!(err, SaasError::CannotDemoteLastOwner));
1525
1526        // Row unchanged.
1527        let after = db.get_tenant_member(&owner).await.unwrap().unwrap();
1528        assert_eq!(after.role, TenantRole::Owner);
1529    }
1530
1531    #[tokio::test]
1532    async fn update_member_role_demote_one_of_two_owners_succeeds() {
1533        let db = ControlDb::new(test_pool().await).await.unwrap();
1534        let tid = seed_tenant(&db, "demote-ok").await;
1535        let first = insert_member_typed(&db, &tid, "a@x.com", "owner").await;
1536        insert_member_typed(&db, &tid, "b@x.com", "owner").await;
1537
1538        db.update_member_role(&first, TenantRole::Admin)
1539            .await
1540            .unwrap();
1541        let after = db.get_tenant_member(&first).await.unwrap().unwrap();
1542        assert_eq!(after.role, TenantRole::Admin);
1543    }
1544
1545    #[tokio::test]
1546    async fn update_role_unknown_member_returns_not_found() {
1547        let db = ControlDb::new(test_pool().await).await.unwrap();
1548        let bogus = MemberId::new();
1549        let err = db
1550            .update_member_role(&bogus, TenantRole::Admin)
1551            .await
1552            .expect_err("unknown");
1553        assert!(matches!(err, SaasError::MemberNotFound));
1554    }
1555
1556    #[tokio::test]
1557    async fn remove_last_owner_fails() {
1558        let db = ControlDb::new(test_pool().await).await.unwrap();
1559        let tid = seed_tenant(&db, "rm-last").await;
1560        let owner = insert_member_typed(&db, &tid, "only@x.com", "owner").await;
1561
1562        let err = db.remove_member(&owner).await.expect_err("remove last");
1563        assert!(matches!(err, SaasError::CannotRemoveLastOwner));
1564        assert!(db.get_tenant_member(&owner).await.unwrap().is_some());
1565    }
1566
1567    #[tokio::test]
1568    async fn remove_one_of_two_owners_succeeds() {
1569        let db = ControlDb::new(test_pool().await).await.unwrap();
1570        let tid = seed_tenant(&db, "rm-ok").await;
1571        let first = insert_member_typed(&db, &tid, "a@x.com", "owner").await;
1572        insert_member_typed(&db, &tid, "b@x.com", "owner").await;
1573
1574        db.remove_member(&first).await.unwrap();
1575        assert!(db.get_tenant_member(&first).await.unwrap().is_none());
1576    }
1577
1578    #[tokio::test]
1579    async fn remove_admin_succeeds() {
1580        let db = ControlDb::new(test_pool().await).await.unwrap();
1581        let tid = seed_tenant(&db, "rm-admin").await;
1582        insert_member_typed(&db, &tid, "owner@x.com", "owner").await;
1583        let admin = insert_member_typed(&db, &tid, "admin@x.com", "admin").await;
1584
1585        db.remove_member(&admin).await.unwrap();
1586        assert!(db.get_tenant_member(&admin).await.unwrap().is_none());
1587    }
1588
1589    // ---- 99c.6 Step 1: super-admin queries ----
1590
1591    #[tokio::test]
1592    async fn count_tenants_by_status_returns_correct_counts() {
1593        let db = ControlDb::new(test_pool().await).await.unwrap();
1594        seed_tenant_with_status(&db, "active-a", "active").await;
1595        seed_tenant_with_status(&db, "active-b", "active").await;
1596        seed_tenant_with_status(&db, "active-c", "active").await;
1597        seed_tenant_with_status(&db, "suspended-a", "suspended").await;
1598        seed_tenant_with_status(&db, "suspended-b", "suspended").await;
1599        seed_tenant_with_status(&db, "deleted-a", "deleted").await;
1600
1601        assert_eq!(
1602            db.count_tenants_by_status(TenantStatus::Active)
1603                .await
1604                .unwrap(),
1605            3
1606        );
1607        assert_eq!(
1608            db.count_tenants_by_status(TenantStatus::Suspended)
1609                .await
1610                .unwrap(),
1611            2
1612        );
1613        assert_eq!(
1614            db.count_tenants_by_status(TenantStatus::Deleted)
1615                .await
1616                .unwrap(),
1617            1
1618        );
1619    }
1620
1621    #[tokio::test]
1622    async fn count_tenants_created_since_threshold() {
1623        let db = ControlDb::new(test_pool().await).await.unwrap();
1624        seed_tenant(&db, "old-a").await;
1625        seed_tenant(&db, "old-b").await;
1626
1627        let threshold = chrono::Utc::now();
1628        seed_tenant(&db, "new-a").await;
1629
1630        // At minimum new-a was created after threshold (all three were inserted
1631        // in tight succession, but at least one must qualify).
1632        let after = db.count_tenants_created_since(threshold).await.unwrap();
1633        let all = db
1634            .count_tenants_created_since(chrono::DateTime::UNIX_EPOCH)
1635            .await
1636            .unwrap();
1637        assert_eq!(all, 3);
1638        assert!(after >= 0 && after <= 3, "threshold count in sane range");
1639    }
1640
1641    #[tokio::test]
1642    async fn count_tenants_grouped_by_plan_returns_all_plans_zero() {
1643        let db = ControlDb::new(test_pool().await).await.unwrap();
1644        // No tenants seeded — all four seed plans should appear with count 0.
1645        let by_plan = db.count_tenants_grouped_by_plan().await.unwrap();
1646        assert_eq!(by_plan.len(), 4, "all 4 seed plans returned");
1647        assert!(by_plan.iter().all(|(_, c)| *c == 0));
1648    }
1649
1650    #[tokio::test]
1651    async fn count_tenants_grouped_by_plan_counts_correctly() {
1652        let db = ControlDb::new(test_pool().await).await.unwrap();
1653        let plans: Vec<(Vec<u8>, String)> =
1654            sqlx::query_as("SELECT id, name FROM tenant_plans ORDER BY price_cents ASC LIMIT 2")
1655                .fetch_all(db.pool())
1656                .await
1657                .unwrap();
1658        let (dev_id, _) = &plans[0];
1659        let (starter_id, _) = &plans[1];
1660
1661        for slug in ["dev-a", "dev-b"] {
1662            let id = uuid::Uuid::new_v4();
1663            sqlx::query(
1664                "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1665                 VALUES (?, ?, ?, ?, ?, 'active', ?)",
1666            )
1667            .bind(id.as_bytes().as_ref())
1668            .bind(slug)
1669            .bind(slug)
1670            .bind(format!("{slug}@x.com"))
1671            .bind(dev_id)
1672            .bind(format!("{slug}.db"))
1673            .execute(db.pool())
1674            .await
1675            .unwrap();
1676        }
1677        let sid = uuid::Uuid::new_v4();
1678        sqlx::query(
1679            "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1680             VALUES (?, 'Starter', 'strt', 'strt@x.com', ?, 'active', 'strt.db')",
1681        )
1682        .bind(sid.as_bytes().as_ref())
1683        .bind(starter_id)
1684        .execute(db.pool())
1685        .await
1686        .unwrap();
1687
1688        let by_plan = db.count_tenants_grouped_by_plan().await.unwrap();
1689        let dev_count = by_plan.iter().find(|(n, _)| n == "dev").map(|(_, c)| *c);
1690        let starter_count = by_plan
1691            .iter()
1692            .find(|(n, _)| n == "starter")
1693            .map(|(_, c)| *c);
1694        assert_eq!(dev_count, Some(2));
1695        assert_eq!(starter_count, Some(1));
1696    }
1697
1698    #[tokio::test]
1699    async fn search_tenants_no_filter_returns_all() {
1700        let db = ControlDb::new(test_pool().await).await.unwrap();
1701        seed_tenant(&db, "alpha").await;
1702        seed_tenant(&db, "beta").await;
1703        seed_tenant(&db, "gamma").await;
1704
1705        let result = db
1706            .search_tenants(SearchTenantsParams {
1707                query: None,
1708                status: None,
1709                plan_id: None,
1710                current_period: "2026-05",
1711                sort_col: TenantSortCol::Name,
1712                sort_dir: SortDir::Asc,
1713                limit: 25,
1714                offset: 0,
1715            })
1716            .await
1717            .unwrap();
1718
1719        assert_eq!(result.total, 3);
1720        assert_eq!(result.rows.len(), 3);
1721        assert_eq!(result.rows[0].slug, "alpha");
1722        assert_eq!(result.rows[2].slug, "gamma");
1723    }
1724
1725    #[tokio::test]
1726    async fn search_tenants_query_matches_slug_and_name() {
1727        let db = ControlDb::new(test_pool().await).await.unwrap();
1728        seed_tenant(&db, "acme").await;
1729        seed_tenant(&db, "acme-staging").await;
1730        seed_tenant(&db, "beta").await;
1731
1732        let result = db
1733            .search_tenants(SearchTenantsParams {
1734                query: Some("acme"),
1735                status: None,
1736                plan_id: None,
1737                current_period: "2026-05",
1738                sort_col: TenantSortCol::Name,
1739                sort_dir: SortDir::Asc,
1740                limit: 25,
1741                offset: 0,
1742            })
1743            .await
1744            .unwrap();
1745
1746        assert_eq!(result.total, 2);
1747        assert!(result.rows.iter().all(|r| r.slug.contains("acme")));
1748    }
1749
1750    #[tokio::test]
1751    async fn search_tenants_status_filter() {
1752        let db = ControlDb::new(test_pool().await).await.unwrap();
1753        seed_tenant_with_status(&db, "susp-a", "suspended").await;
1754        seed_tenant_with_status(&db, "susp-b", "suspended").await;
1755        seed_tenant(&db, "active-z").await;
1756
1757        let result = db
1758            .search_tenants(SearchTenantsParams {
1759                query: None,
1760                status: Some(TenantStatus::Suspended),
1761                plan_id: None,
1762                current_period: "2026-05",
1763                sort_col: TenantSortCol::Name,
1764                sort_dir: SortDir::Asc,
1765                limit: 25,
1766                offset: 0,
1767            })
1768            .await
1769            .unwrap();
1770
1771        assert_eq!(result.total, 2);
1772        assert!(
1773            result
1774                .rows
1775                .iter()
1776                .all(|r| r.status == TenantStatus::Suspended)
1777        );
1778    }
1779
1780    #[tokio::test]
1781    async fn search_tenants_pagination() {
1782        let db = ControlDb::new(test_pool().await).await.unwrap();
1783        for i in 0..30u32 {
1784            seed_tenant(&db, &format!("page-t-{i:02}")).await;
1785        }
1786
1787        let page1 = db
1788            .search_tenants(SearchTenantsParams {
1789                query: None,
1790                status: None,
1791                plan_id: None,
1792                current_period: "2026-05",
1793                sort_col: TenantSortCol::Name,
1794                sort_dir: SortDir::Asc,
1795                limit: 25,
1796                offset: 0,
1797            })
1798            .await
1799            .unwrap();
1800        let page2 = db
1801            .search_tenants(SearchTenantsParams {
1802                query: None,
1803                status: None,
1804                plan_id: None,
1805                current_period: "2026-05",
1806                sort_col: TenantSortCol::Name,
1807                sort_dir: SortDir::Asc,
1808                limit: 25,
1809                offset: 25,
1810            })
1811            .await
1812            .unwrap();
1813
1814        assert_eq!(page1.total, 30);
1815        assert_eq!(page1.rows.len(), 25);
1816        assert_eq!(page2.rows.len(), 5);
1817    }
1818
1819    #[tokio::test]
1820    async fn search_tenants_sort_name_desc() {
1821        let db = ControlDb::new(test_pool().await).await.unwrap();
1822        seed_tenant(&db, "aaa").await;
1823        seed_tenant(&db, "bbb").await;
1824        seed_tenant(&db, "ccc").await;
1825
1826        let result = db
1827            .search_tenants(SearchTenantsParams {
1828                query: None,
1829                status: None,
1830                plan_id: None,
1831                current_period: "2026-05",
1832                sort_col: TenantSortCol::Name,
1833                sort_dir: SortDir::Desc,
1834                limit: 25,
1835                offset: 0,
1836            })
1837            .await
1838            .unwrap();
1839
1840        assert_eq!(result.rows[0].slug, "ccc");
1841        assert_eq!(result.rows[2].slug, "aaa");
1842    }
1843
1844    #[tokio::test]
1845    async fn aggregate_usage_for_period_returns_zeros_when_empty() {
1846        let db = ControlDb::new(test_pool().await).await.unwrap();
1847        let agg = db.aggregate_usage_for_period("2026-05").await.unwrap();
1848        assert_eq!(agg.period, "2026-05");
1849        assert_eq!(agg.total_mau, 0);
1850        assert_eq!(agg.active_tenants, 0);
1851    }
1852
1853    #[tokio::test]
1854    async fn aggregate_usage_for_period_sums_correctly() {
1855        let db = ControlDb::new(test_pool().await).await.unwrap();
1856        let ta = seed_tenant(&db, "agg-a").await;
1857        let tb = seed_tenant(&db, "agg-b").await;
1858        let tc = seed_tenant(&db, "agg-c").await;
1859
1860        for (tid, mau) in [(&ta, 100i64), (&tb, 200), (&tc, 0)] {
1861            let uid = uuid::Uuid::new_v4();
1862            sqlx::query(
1863                "INSERT INTO tenant_usage (id, tenant_id, period, mau_count) \
1864                 VALUES (?, ?, '2026-05', ?)",
1865            )
1866            .bind(uid.as_bytes().as_ref())
1867            .bind(tid.as_bytes())
1868            .bind(mau)
1869            .execute(db.pool())
1870            .await
1871            .unwrap();
1872        }
1873
1874        let agg = db.aggregate_usage_for_period("2026-05").await.unwrap();
1875        assert_eq!(agg.total_mau, 300);
1876        // tc has mau_count=0 → not counted as active.
1877        assert_eq!(agg.active_tenants, 2);
1878    }
1879
1880    #[tokio::test]
1881    async fn aggregate_usage_history_returns_n_most_recent() {
1882        let db = ControlDb::new(test_pool().await).await.unwrap();
1883        let tid = seed_tenant(&db, "hist").await;
1884
1885        // Seed 14 periods.
1886        for i in 0..14u32 {
1887            let uid = uuid::Uuid::new_v4();
1888            let period = format!("2025-{:02}", i + 1);
1889            sqlx::query(
1890                "INSERT INTO tenant_usage (id, tenant_id, period, mau_count) \
1891                 VALUES (?, ?, ?, 1)",
1892            )
1893            .bind(uid.as_bytes().as_ref())
1894            .bind(tid.as_bytes())
1895            .bind(&period)
1896            .execute(db.pool())
1897            .await
1898            .unwrap();
1899        }
1900
1901        let history = db.aggregate_usage_history(12).await.unwrap();
1902        assert_eq!(
1903            history.len(),
1904            12,
1905            "only the 12 most recent periods returned"
1906        );
1907        // Ordered newest first.
1908        assert!(history[0].period > history[11].period);
1909    }
1910
1911    #[tokio::test]
1912    async fn count_dormant_tenants_excludes_deleted() {
1913        let db = ControlDb::new(test_pool().await).await.unwrap();
1914        // Active with no last_seen_at → dormant.
1915        seed_tenant(&db, "never-seen").await;
1916        // Deleted → excluded regardless.
1917        seed_tenant_with_status(&db, "del", "deleted").await;
1918
1919        let n = db
1920            .count_dormant_tenants(chrono::Utc::now() + chrono::Duration::days(365))
1921            .await
1922            .unwrap();
1923        assert_eq!(n, 1, "deleted tenant must not be counted as dormant");
1924    }
1925
1926    #[tokio::test]
1927    async fn count_dormant_tenants_excludes_recently_seen() {
1928        let db = ControlDb::new(test_pool().await).await.unwrap();
1929        let tid = seed_tenant(&db, "recent-tenant").await;
1930        db.touch_last_seen(&tid).await.unwrap();
1931
1932        // Threshold is in the past → recently-seen tenant is not dormant.
1933        let n = db
1934            .count_dormant_tenants(chrono::Utc::now() - chrono::Duration::days(30))
1935            .await
1936            .unwrap();
1937        assert_eq!(n, 0, "recently-seen tenant should not be dormant");
1938    }
1939
1940    #[tokio::test]
1941    async fn set_tenant_status_updates_row() {
1942        let db = ControlDb::new(test_pool().await).await.unwrap();
1943        let tid = seed_tenant(&db, "suspend-me").await;
1944
1945        db.set_tenant_status(&tid, TenantStatus::Suspended)
1946            .await
1947            .unwrap();
1948
1949        let (status,): (String,) = sqlx::query_as("SELECT status FROM tenants WHERE id = ?1")
1950            .bind(tid.as_bytes())
1951            .fetch_one(db.pool())
1952            .await
1953            .unwrap();
1954        assert_eq!(status, "suspended");
1955    }
1956
1957    #[tokio::test]
1958    async fn set_tenant_plan_updates_plan_id() {
1959        let db = ControlDb::new(test_pool().await).await.unwrap();
1960        let tid = seed_tenant(&db, "change-plan").await;
1961        let plans = db.list_plans().await.unwrap();
1962        // dev is plan index 0; pick the next one.
1963        let new_plan = &plans[1];
1964
1965        db.set_tenant_plan(&tid, &new_plan.id).await.unwrap();
1966
1967        let (plan_id,): (Vec<u8>,) = sqlx::query_as("SELECT plan_id FROM tenants WHERE id = ?1")
1968            .bind(tid.as_bytes())
1969            .fetch_one(db.pool())
1970            .await
1971            .unwrap();
1972        assert_eq!(plan_id, new_plan.id);
1973    }
1974
1975    #[tokio::test]
1976    async fn list_plans_returns_four_seed_plans_ordered_by_price() {
1977        let db = ControlDb::new(test_pool().await).await.unwrap();
1978        let plans = db.list_plans().await.unwrap();
1979        assert_eq!(plans.len(), 4);
1980        let names: Vec<&str> = plans.iter().map(|p| p.name.as_str()).collect();
1981        assert!(names.contains(&"dev"));
1982        assert!(names.contains(&"starter"));
1983        assert!(names.contains(&"growth"));
1984        assert!(names.contains(&"scale"));
1985        // Cheapest first.
1986        assert_eq!(plans[0].name, "dev");
1987        assert_eq!(plans[0].price_cents, 0);
1988    }
1989
1990    #[tokio::test]
1991    async fn get_plan_by_id_some_and_none() {
1992        let db = ControlDb::new(test_pool().await).await.unwrap();
1993        let plans = db.list_plans().await.unwrap();
1994        let first = &plans[0];
1995
1996        let found = db.get_plan_by_id(&first.id).await.unwrap();
1997        assert!(found.is_some());
1998        assert_eq!(found.unwrap().name, first.name);
1999
2000        let bogus = vec![0u8; 16];
2001        let not_found = db.get_plan_by_id(&bogus).await.unwrap();
2002        assert!(not_found.is_none());
2003    }
2004
2005    // ── tenant_domains CRUD ───────────────────────────────────────────────────
2006
2007    #[tokio::test]
2008    async fn create_tenant_domain_inserts_pending() {
2009        use crate::domains::DomainStatus;
2010
2011        let db = ControlDb::new(test_pool().await).await.unwrap();
2012        let tid = seed_tenant(&db, "dom-insert").await;
2013
2014        let row = db
2015            .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2016            .await
2017            .unwrap();
2018
2019        assert_eq!(row.domain, "auth.example.com");
2020        assert_eq!(row.dns_target, "custom.allowthem.io");
2021        assert_eq!(row.status, DomainStatus::PendingVerification);
2022        assert!(row.verified_at.is_none());
2023        assert!(row.last_error.is_none());
2024    }
2025
2026    #[tokio::test]
2027    async fn create_tenant_domain_unique_violation_returns_conflict() {
2028        let db = ControlDb::new(test_pool().await).await.unwrap();
2029        let tid = seed_tenant(&db, "dom-conflict").await;
2030
2031        db.create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2032            .await
2033            .unwrap();
2034
2035        let err = db
2036            .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2037            .await
2038            .unwrap_err();
2039        assert!(
2040            matches!(err, SaasError::DomainAlreadyExists),
2041            "expected DomainAlreadyExists, got {err:?}"
2042        );
2043    }
2044
2045    #[tokio::test]
2046    async fn list_tenant_domains_orders_by_created_at_desc() {
2047        let db = ControlDb::new(test_pool().await).await.unwrap();
2048        let tid = seed_tenant(&db, "dom-list-order").await;
2049
2050        db.create_tenant_domain(&tid, "a.example.com", "custom.allowthem.io")
2051            .await
2052            .unwrap();
2053        db.create_tenant_domain(&tid, "b.example.com", "custom.allowthem.io")
2054            .await
2055            .unwrap();
2056        db.create_tenant_domain(&tid, "c.example.com", "custom.allowthem.io")
2057            .await
2058            .unwrap();
2059
2060        sqlx::query("UPDATE tenant_domains SET created_at = ?1 WHERE tenant_id = ?2")
2061            .bind("2026-01-01T00:00:00.000Z")
2062            .bind(tid.as_bytes())
2063            .execute(db.pool())
2064            .await
2065            .unwrap();
2066
2067        let rows = db.list_tenant_domains(&tid).await.unwrap();
2068        assert_eq!(rows.len(), 3);
2069        // Same created_at values need a stable newest-first tiebreaker.
2070        assert_eq!(rows[0].domain, "c.example.com");
2071        assert_eq!(rows[1].domain, "b.example.com");
2072        assert_eq!(rows[2].domain, "a.example.com");
2073    }
2074
2075    #[tokio::test]
2076    async fn list_tenant_domains_filters_by_tenant_id() {
2077        let db = ControlDb::new(test_pool().await).await.unwrap();
2078        let tid_a = seed_tenant(&db, "dom-filter-a").await;
2079        let tid_b = seed_tenant(&db, "dom-filter-b").await;
2080
2081        db.create_tenant_domain(&tid_a, "auth.acme.com", "custom.allowthem.io")
2082            .await
2083            .unwrap();
2084        db.create_tenant_domain(&tid_b, "auth.beta.com", "custom.allowthem.io")
2085            .await
2086            .unwrap();
2087
2088        let rows_a = db.list_tenant_domains(&tid_a).await.unwrap();
2089        assert_eq!(rows_a.len(), 1);
2090        assert_eq!(rows_a[0].domain, "auth.acme.com");
2091
2092        let rows_b = db.list_tenant_domains(&tid_b).await.unwrap();
2093        assert_eq!(rows_b.len(), 1);
2094        assert_eq!(rows_b[0].domain, "auth.beta.com");
2095    }
2096
2097    #[tokio::test]
2098    async fn get_tenant_domain_scoped_other_tenant_is_none() {
2099        use crate::domains::DomainId;
2100
2101        let db = ControlDb::new(test_pool().await).await.unwrap();
2102        let tid_a = seed_tenant(&db, "dom-scope-a").await;
2103        let tid_b = seed_tenant(&db, "dom-scope-b").await;
2104
2105        let row = db
2106            .create_tenant_domain(&tid_a, "auth.acme.com", "custom.allowthem.io")
2107            .await
2108            .unwrap();
2109        let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2110
2111        // Correct tenant sees the row.
2112        let found = db.get_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2113        assert!(found.is_some());
2114
2115        // Other tenant gets None.
2116        let not_found = db.get_tenant_domain_scoped(&did, &tid_b).await.unwrap();
2117        assert!(not_found.is_none());
2118    }
2119
2120    #[tokio::test]
2121    async fn set_tenant_domain_status_to_verified_clears_last_error_and_sets_verified_at() {
2122        use crate::domains::{DomainId, DomainStatus};
2123
2124        let db = ControlDb::new(test_pool().await).await.unwrap();
2125        let tid = seed_tenant(&db, "dom-verify").await;
2126
2127        let row = db
2128            .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2129            .await
2130            .unwrap();
2131        let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2132
2133        let now = chrono::Utc::now();
2134        db.set_tenant_domain_status(&did, DomainStatus::Verified, Some(now), None)
2135            .await
2136            .unwrap();
2137
2138        let updated = db
2139            .get_tenant_domain_scoped(&did, &tid)
2140            .await
2141            .unwrap()
2142            .unwrap();
2143        assert_eq!(updated.status, DomainStatus::Verified);
2144        assert!(updated.verified_at.is_some());
2145        assert!(updated.last_error.is_none());
2146    }
2147
2148    #[tokio::test]
2149    async fn set_tenant_domain_status_to_failed_records_last_error() {
2150        use crate::domains::{DomainId, DomainStatus};
2151
2152        let db = ControlDb::new(test_pool().await).await.unwrap();
2153        let tid = seed_tenant(&db, "dom-fail").await;
2154
2155        let row = db
2156            .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2157            .await
2158            .unwrap();
2159        let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2160
2161        db.set_tenant_domain_status(
2162            &did,
2163            DomainStatus::Failed,
2164            None,
2165            Some("no CNAME record found"),
2166        )
2167        .await
2168        .unwrap();
2169
2170        let updated = db
2171            .get_tenant_domain_scoped(&did, &tid)
2172            .await
2173            .unwrap()
2174            .unwrap();
2175        assert_eq!(updated.status, DomainStatus::Failed);
2176        assert!(updated.verified_at.is_none());
2177        assert_eq!(updated.last_error.as_deref(), Some("no CNAME record found"));
2178    }
2179
2180    #[tokio::test]
2181    async fn delete_tenant_domain_scoped_other_tenant_is_no_op() {
2182        use crate::domains::DomainId;
2183
2184        let db = ControlDb::new(test_pool().await).await.unwrap();
2185        let tid_a = seed_tenant(&db, "dom-del-a").await;
2186        let tid_b = seed_tenant(&db, "dom-del-b").await;
2187
2188        let row = db
2189            .create_tenant_domain(&tid_a, "auth.example.com", "custom.allowthem.io")
2190            .await
2191            .unwrap();
2192        let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2193
2194        // Deleting from the wrong tenant returns false (no rows affected).
2195        let affected = db.delete_tenant_domain_scoped(&did, &tid_b).await.unwrap();
2196        assert!(!affected);
2197
2198        // Row still exists for the correct tenant.
2199        let still_there = db.get_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2200        assert!(still_there.is_some());
2201
2202        // Correct tenant can delete.
2203        let deleted = db.delete_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2204        assert!(deleted);
2205
2206        let gone = db.get_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2207        assert!(gone.is_none());
2208    }
2209
2210    #[tokio::test]
2211    async fn tenant_by_custom_domain_returns_only_verified_or_active() {
2212        use crate::domains::{DomainId, DomainStatus};
2213
2214        let db = ControlDb::new(test_pool().await).await.unwrap();
2215        let tid = seed_tenant(&db, "dom-router").await;
2216
2217        // pending_verification → should not be returned
2218        let row = db
2219            .create_tenant_domain(&tid, "pending.example.com", "custom.allowthem.io")
2220            .await
2221            .unwrap();
2222        let did_pending = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2223        assert!(
2224            db.tenant_by_custom_domain("pending.example.com")
2225                .await
2226                .unwrap()
2227                .is_none()
2228        );
2229
2230        // Flip to verified → should be returned
2231        db.set_tenant_domain_status(
2232            &did_pending,
2233            DomainStatus::Verified,
2234            Some(chrono::Utc::now()),
2235            None,
2236        )
2237        .await
2238        .unwrap();
2239        let found = db
2240            .tenant_by_custom_domain("pending.example.com")
2241            .await
2242            .unwrap();
2243        assert!(found.is_some());
2244        assert_eq!(found.unwrap().slug, "dom-router");
2245
2246        // failed → not returned
2247        let row2 = db
2248            .create_tenant_domain(&tid, "failed.example.com", "custom.allowthem.io")
2249            .await
2250            .unwrap();
2251        let did_failed = DomainId::from(Uuid::from_slice(&row2.id).unwrap());
2252        db.set_tenant_domain_status(&did_failed, DomainStatus::Failed, None, Some("err"))
2253            .await
2254            .unwrap();
2255        assert!(
2256            db.tenant_by_custom_domain("failed.example.com")
2257                .await
2258                .unwrap()
2259                .is_none()
2260        );
2261    }
2262
2263    #[tokio::test]
2264    async fn tenant_by_custom_domain_lowercases_input() {
2265        use crate::domains::{DomainId, DomainStatus};
2266
2267        let db = ControlDb::new(test_pool().await).await.unwrap();
2268        let tid = seed_tenant(&db, "dom-case").await;
2269
2270        let row = db
2271            .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2272            .await
2273            .unwrap();
2274        let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2275        db.set_tenant_domain_status(&did, DomainStatus::Verified, Some(chrono::Utc::now()), None)
2276            .await
2277            .unwrap();
2278
2279        // Query with mixed case — should still find the row.
2280        let found = db
2281            .tenant_by_custom_domain("Auth.EXAMPLE.COM")
2282            .await
2283            .unwrap();
2284        assert!(found.is_some());
2285    }
2286
2287    #[tokio::test]
2288    async fn list_domains_for_sweep_oldest_first_capped() {
2289        use crate::domains::{DomainId, DomainStatus};
2290
2291        let db = ControlDb::new(test_pool().await).await.unwrap();
2292        let tid = seed_tenant(&db, "dom-sweep").await;
2293
2294        // Insert three pending rows.
2295        for domain in ["a.sweep.com", "b.sweep.com", "c.sweep.com"] {
2296            db.create_tenant_domain(&tid, domain, "custom.allowthem.io")
2297                .await
2298                .unwrap();
2299        }
2300
2301        // Insert one verified row — should not appear.
2302        let row = db
2303            .create_tenant_domain(&tid, "v.sweep.com", "custom.allowthem.io")
2304            .await
2305            .unwrap();
2306        let did_v = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2307        db.set_tenant_domain_status(
2308            &did_v,
2309            DomainStatus::Verified,
2310            Some(chrono::Utc::now()),
2311            None,
2312        )
2313        .await
2314        .unwrap();
2315
2316        // Cap at 2 — only 2 of the 3 pending rows returned.
2317        let sweep = db.list_domains_for_sweep(2).await.unwrap();
2318        assert_eq!(sweep.len(), 2);
2319        // None should be verified.
2320        for r in &sweep {
2321            assert!(
2322                matches!(
2323                    r.status,
2324                    DomainStatus::PendingVerification | DomainStatus::Failed
2325                ),
2326                "unexpected status {:?}",
2327                r.status
2328            );
2329        }
2330    }
2331}