Skip to main content

autumn_admin_plugin/
feature_flags.rs

1//! Admin panel model for `autumn_feature_flags`.
2//!
3//! Registers a flag management page at `/admin/feature-flags/` with:
4//! - List view: key, enabled status, rollout %, actor allowlist, history link
5//! - Edit view: toggle enabled, set `rollout_pct`, manage allowlists
6//! - History tab: per-flag audit trail from `feature_flag_changes`
7
8use serde_json::Value;
9
10use crate::{
11    AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
12    SelectOption,
13};
14
15/// Admin panel model for feature flags.
16///
17/// # Postgres only
18///
19/// This model reads and writes `autumn_feature_flags`. That table is Postgres-only.
20/// Its SQL uses `ILIKE`, `::type` casts and writable CTEs, which `SQLite` does
21/// not have. On `SQLite` every method refuses with an error that names this
22/// model. The plugin core is backend-agnostic: register your own
23/// [`AdminModel`](crate::AdminModel)s there instead. See the crate README.
24///
25/// Register this model with the admin plugin to get a flag management UI
26/// at `/admin/feature-flags/`:
27///
28/// ```rust,ignore
29/// use autumn_admin_plugin::{prelude::*, AdminPlugin};
30/// use autumn_admin_plugin::feature_flags::FeatureFlagAdminModel;
31///
32/// autumn_web::app()
33///     .plugin(
34///         AdminPlugin::new()
35///             .register(FeatureFlagAdminModel::default()),
36///     )
37///     .run()
38///     .await;
39/// ```
40#[derive(Debug, Default, Clone)]
41pub struct FeatureFlagAdminModel;
42
43impl AdminModel for FeatureFlagAdminModel {
44    fn slug(&self) -> &'static str {
45        "feature-flags"
46    }
47
48    fn display_name(&self) -> &'static str {
49        "Feature Flag"
50    }
51
52    fn display_name_plural(&self) -> &'static str {
53        "Feature Flags"
54    }
55
56    fn fields(&self) -> Vec<AdminField> {
57        vec![
58            AdminField::new("key", AdminFieldKind::Text)
59                .label("Flag Key")
60                .searchable(),
61            AdminField::new("description", AdminFieldKind::TextArea)
62                .label("Description")
63                .optional()
64                .searchable(),
65            AdminField::new("enabled", AdminFieldKind::Boolean).label("Globally Enabled"),
66            AdminField::new(
67                "rollout_pct",
68                AdminFieldKind::Select(vec![
69                    SelectOption {
70                        value: "0".into(),
71                        label: "Off (0%)".into(),
72                    },
73                    SelectOption {
74                        value: "10".into(),
75                        label: "10%".into(),
76                    },
77                    SelectOption {
78                        value: "25".into(),
79                        label: "25%".into(),
80                    },
81                    SelectOption {
82                        value: "50".into(),
83                        label: "50%".into(),
84                    },
85                    SelectOption {
86                        value: "75".into(),
87                        label: "75%".into(),
88                    },
89                    SelectOption {
90                        value: "100".into(),
91                        label: "All (100%)".into(),
92                    },
93                ]),
94            )
95            .label("Rollout %")
96            .optional(),
97            AdminField::new("actor_allowlist", AdminFieldKind::TextArea)
98                .label("Actor Allowlist (JSON array)")
99                .optional()
100                .hide_from_list(),
101            AdminField::new("group_allowlist", AdminFieldKind::TextArea)
102                .label("Group Allowlist (JSON array)")
103                .optional()
104                .hide_from_list(),
105            AdminField::new("updated_at", AdminFieldKind::DateTime)
106                .label("Last Updated")
107                .readonly()
108                .optional(),
109        ]
110    }
111
112    fn record_display(&self, record: &Value) -> String {
113        record
114            .get("key")
115            .and_then(|v| v.as_str())
116            .map_or_else(|| "Feature Flag".to_owned(), |k| format!("Flag: {k}"))
117    }
118
119    fn list(
120        &self,
121        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
122        params: ListParams,
123    ) -> AdminFuture<'_, ListResult> {
124        use diesel_async::RunQueryDsl;
125
126        let pool = pool.clone();
127        Box::pin(async move {
128            crate::traits::require_postgres("FeatureFlagAdminModel")?;
129            let mut conn = pool
130                .get()
131                .await
132                .map_err(|e| AdminError::Database(e.to_string()))?;
133
134            let per_page = params.per_page;
135            let (offset, limit) = params.sql_offset_limit();
136
137            // Parameterized search — `%` alone matches everything (no search case).
138            let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
139
140            let total: i64 = diesel::sql_query(
141                "SELECT COUNT(*) FROM autumn_feature_flags \
142                 WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1)",
143            )
144            .bind::<diesel::sql_types::Text, _>(&search_pattern)
145            .get_result::<CountRow>(&mut conn)
146            .await
147            .map_or(0, |r| r.count);
148
149            let records: Vec<Value> = diesel::sql_query(
150                "SELECT id, key, description, enabled, rollout_pct, \
151                        actor_allowlist, group_allowlist, updated_at \
152                 FROM autumn_feature_flags \
153                 WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1) \
154                 ORDER BY key \
155                 LIMIT $2 OFFSET $3",
156            )
157            .bind::<diesel::sql_types::Text, _>(&search_pattern)
158            .bind::<diesel::sql_types::BigInt, _>(limit)
159            .bind::<diesel::sql_types::BigInt, _>(offset)
160            .load::<FlagRow>(&mut conn)
161            .await
162            .map(|rows| rows.into_iter().map(FlagRow::into_json).collect())
163            .map_err(|e| AdminError::Database(e.to_string()))?;
164
165            Ok(ListResult {
166                total: u64::try_from(total).unwrap_or(0),
167                page: params.page,
168                per_page,
169                records,
170            })
171        })
172    }
173
174    fn get(
175        &self,
176        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
177        id: i64,
178    ) -> AdminFuture<'_, Option<Value>> {
179        use diesel::prelude::*;
180        use diesel_async::RunQueryDsl;
181
182        let pool = pool.clone();
183        Box::pin(async move {
184            crate::traits::require_postgres("FeatureFlagAdminModel")?;
185            let mut conn = pool
186                .get()
187                .await
188                .map_err(|e| AdminError::Database(e.to_string()))?;
189
190            diesel::sql_query(
191                "SELECT id, key, description, enabled, rollout_pct, \
192                        actor_allowlist, group_allowlist, updated_at \
193                 FROM autumn_feature_flags WHERE id = $1",
194            )
195            .bind::<diesel::sql_types::BigInt, _>(id)
196            .get_result::<FlagRow>(&mut conn)
197            .await
198            .optional()
199            .map(|r| r.map(FlagRow::into_json))
200            .map_err(|e| AdminError::Database(e.to_string()))
201        })
202    }
203
204    fn create(
205        &self,
206        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
207        data: Value,
208    ) -> AdminFuture<'_, Value> {
209        use diesel_async::RunQueryDsl;
210
211        let pool = pool.clone();
212        Box::pin(async move {
213            crate::traits::require_postgres("FeatureFlagAdminModel")?;
214            let mut conn = pool
215                .get()
216                .await
217                .map_err(|e| AdminError::Database(e.to_string()))?;
218
219            let key = data
220                .get("key")
221                .and_then(Value::as_str)
222                .ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
223            let enabled = data
224                .get("enabled")
225                .and_then(Value::as_bool)
226                .unwrap_or(false);
227            // Select widget sends strings ("25"), direct API sends numbers.
228            let mut rollout_pct = data
229                .get("rollout_pct")
230                .and_then(|v| {
231                    v.as_i64()
232                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
233                })
234                .unwrap_or(0)
235                .clamp(0, 100);
236            let description = data.get("description").and_then(Value::as_str);
237            let actor_allowlist = validate_string_array(
238                data.get("actor_allowlist")
239                    .and_then(Value::as_str)
240                    .unwrap_or("[]"),
241                "actor_allowlist",
242            )?;
243            let group_allowlist = validate_string_array(
244                data.get("group_allowlist")
245                    .and_then(Value::as_str)
246                    .unwrap_or("[]"),
247                "group_allowlist",
248            )?;
249
250            // "Globally Enabled" with empty allowlists means globally on for all
251            // actors — promote rollout_pct to 100 so the evaluator agrees.
252            // When non-empty allowlists are provided, don't promote: the intent
253            // is allowlist-only access, not global rollout.
254            let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
255            if enabled && rollout_pct == 0 && !has_allowlist {
256                rollout_pct = 100;
257            }
258
259            let mutation = if enabled { "enabled" } else { "disabled" };
260
261            // A CTE combines the INSERT and audit-log write into one atomic
262            // statement.  Using a plain INSERT (no ON CONFLICT) means a duplicate
263            // key rejects with a validation error rather than silently overwriting
264            // a live flag via the admin "new record" form.
265            let row = diesel::sql_query(
266                "WITH inserted AS ( \
267                     INSERT INTO autumn_feature_flags \
268                         (key, description, enabled, rollout_pct, \
269                          actor_allowlist, group_allowlist) \
270                     VALUES ($1, $2, $3, $4, $5, $6) \
271                     RETURNING id, key, description, enabled, rollout_pct, \
272                               actor_allowlist, group_allowlist, updated_at \
273                 ), \
274                 _audit AS ( \
275                     INSERT INTO feature_flag_changes (key, mutation, actor) \
276                     SELECT key, $7, NULL FROM inserted \
277                 ) \
278                 SELECT id, key, description, enabled, rollout_pct, \
279                        actor_allowlist, group_allowlist, updated_at \
280                 FROM inserted",
281            )
282            .bind::<diesel::sql_types::Text, _>(key)
283            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
284                description.map(str::to_owned),
285            )
286            .bind::<diesel::sql_types::Bool, _>(enabled)
287            .bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
288            .bind::<diesel::sql_types::Text, _>(actor_allowlist)
289            .bind::<diesel::sql_types::Text, _>(group_allowlist)
290            .bind::<diesel::sql_types::Text, _>(mutation)
291            .get_result::<FlagRow>(&mut conn)
292            .await
293            .map_err(|e| {
294                if matches!(
295                    e,
296                    diesel::result::Error::DatabaseError(
297                        diesel::result::DatabaseErrorKind::UniqueViolation,
298                        _
299                    )
300                ) {
301                    AdminError::Validation(format!("a flag with key '{key}' already exists"))
302                } else {
303                    AdminError::Database(e.to_string())
304                }
305            })?;
306
307            Ok(FlagRow::into_json(row))
308        })
309    }
310
311    fn update(
312        &self,
313        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
314        id: i64,
315        data: Value,
316    ) -> AdminFuture<'_, Value> {
317        use diesel_async::RunQueryDsl;
318
319        let pool = pool.clone();
320        Box::pin(async move {
321            crate::traits::require_postgres("FeatureFlagAdminModel")?;
322            let mut conn = pool
323                .get()
324                .await
325                .map_err(|e| AdminError::Database(e.to_string()))?;
326
327            let key = data
328                .get("key")
329                .and_then(Value::as_str)
330                .ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
331            let enabled = data
332                .get("enabled")
333                .and_then(Value::as_bool)
334                .unwrap_or(false);
335            let mut rollout_pct = data
336                .get("rollout_pct")
337                .and_then(|v| {
338                    v.as_i64()
339                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
340                })
341                .unwrap_or(0)
342                .clamp(0, 100);
343            let description = data.get("description").and_then(Value::as_str);
344            let actor_allowlist = validate_string_array(
345                data.get("actor_allowlist")
346                    .and_then(Value::as_str)
347                    .unwrap_or("[]"),
348                "actor_allowlist",
349            )?;
350            let group_allowlist = validate_string_array(
351                data.get("group_allowlist")
352                    .and_then(Value::as_str)
353                    .unwrap_or("[]"),
354                "group_allowlist",
355            )?;
356            let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
357            if enabled && rollout_pct == 0 && !has_allowlist {
358                rollout_pct = 100;
359            }
360
361            let mutation = if enabled { "enabled" } else { "disabled" };
362
363            // A CTE combines the key lookup, UPDATE, and both audit-log writes
364            // into one atomic statement.  'old_row' reads the pre-update key so
365            // a rename emits a 'deleted' invalidation for the old name;
366            // '_audit_rename' is a no-op when the key is unchanged.
367            let row = diesel::sql_query(
368                "WITH old_row AS ( \
369                     SELECT key FROM autumn_feature_flags WHERE id = $1 \
370                 ), \
371                 updated AS ( \
372                     UPDATE autumn_feature_flags \
373                     SET key = $2, description = $3, enabled = $4, rollout_pct = $5, \
374                         actor_allowlist = $6, group_allowlist = $7, updated_at = NOW() \
375                     WHERE id = $1 \
376                     RETURNING id, key, description, enabled, rollout_pct, \
377                               actor_allowlist, group_allowlist, updated_at \
378                 ), \
379                 _audit_rename AS ( \
380                     INSERT INTO feature_flag_changes (key, mutation, actor) \
381                     SELECT old_row.key, 'deleted', NULL \
382                     FROM old_row \
383                     WHERE old_row.key != $2 \
384                 ), \
385                 _audit_rename_breadcrumb AS ( \
386                     INSERT INTO feature_flag_changes (key, mutation, actor) \
387                     SELECT $2, 'renamed_from=' || old_row.key, NULL \
388                     FROM old_row \
389                     WHERE old_row.key != $2 \
390                 ), \
391                 _audit AS ( \
392                     INSERT INTO feature_flag_changes (key, mutation, actor) \
393                     SELECT key, $8, NULL FROM updated \
394                 ) \
395                 SELECT id, key, description, enabled, rollout_pct, \
396                        actor_allowlist, group_allowlist, updated_at \
397                 FROM updated",
398            )
399            .bind::<diesel::sql_types::BigInt, _>(id)
400            .bind::<diesel::sql_types::Text, _>(key)
401            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
402                description.map(str::to_owned),
403            )
404            .bind::<diesel::sql_types::Bool, _>(enabled)
405            .bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
406            .bind::<diesel::sql_types::Text, _>(actor_allowlist)
407            .bind::<diesel::sql_types::Text, _>(group_allowlist)
408            .bind::<diesel::sql_types::Text, _>(mutation)
409            .get_result::<FlagRow>(&mut conn)
410            .await
411            .map_err(|e| AdminError::Database(e.to_string()))?;
412
413            Ok(FlagRow::into_json(row))
414        })
415    }
416
417    fn delete(
418        &self,
419        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
420        id: i64,
421    ) -> AdminFuture<'_, ()> {
422        use diesel_async::RunQueryDsl;
423
424        let pool = pool.clone();
425        Box::pin(async move {
426            crate::traits::require_postgres("FeatureFlagAdminModel")?;
427            let mut conn = pool
428                .get()
429                .await
430                .map_err(|e| AdminError::Database(e.to_string()))?;
431
432            // A CTE combines the DELETE and audit-log write into one atomic
433            // statement so that cache invalidation always fires together with
434            // the row removal.
435            diesel::sql_query(
436                "WITH deleted AS ( \
437                     DELETE FROM autumn_feature_flags WHERE id = $1 RETURNING key \
438                 ), \
439                 _audit AS ( \
440                     INSERT INTO feature_flag_changes (key, mutation, actor) \
441                     SELECT key, 'deleted', NULL FROM deleted \
442                 ) \
443                 SELECT COUNT(*) AS count FROM deleted",
444            )
445            .bind::<diesel::sql_types::BigInt, _>(id)
446            .get_result::<CountRow>(&mut conn)
447            .await
448            .map_err(|e| AdminError::Database(e.to_string()))?;
449
450            Ok(())
451        })
452    }
453
454    fn execute_action(
455        &self,
456        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
457        action: &str,
458        ids: Vec<i64>,
459    ) -> AdminFuture<'_, u64> {
460        // `FeatureFlagAdminModel` never declares soft delete
461        // (`supports_soft_delete()` is the trait default, `false`), so
462        // `actions()` (traits.rs) only ever offers `"delete"` — the admin UI
463        // can't reach `"restore"` or `"purge"` for this model. Only `"delete"`
464        // needs the batched fast path below; `"restore"`, `"purge"`, and any
465        // other action name fall through to the shared
466        // `dispatch_restore_purge_or_unhandled` helper (traits.rs), which a
467        // direct or out-of-band `execute_action` call still reaches for the
468        // same "does not support soft delete" (or "unhandled action") error
469        // it always got.
470        if action == "delete" {
471            let pool = pool.clone();
472            return Box::pin(async move {
473                // The batched form binds a Postgres array. SQLite has no array
474                // bind type. `backend_select!` keeps one arm and drops the
475                // other, so the array never reaches the SQLite type-checker
476                // (issue #2108).
477                //
478                // The SQLite arm keeps the crate compiling, and refuses.
479                // FeatureFlagAdminModel is Postgres-only, so there is no
480                // correct SQLite statement to fall back to.
481                ::autumn_web::backend_select! {
482                    pg => {{
483                        use diesel_async::RunQueryDsl;
484
485                        // Batch every id into ONE round trip instead of the trait
486                        // default's one-CTE-per-id loop (an operator selecting
487                        // hundreds of stale flags in the admin list and clicking
488                        // "Delete selected" otherwise costs one statement, and one
489                        // connection checkout, per flag). Same CTE shape as the
490                        // single-row `delete()`: the audit INSERT's `SELECT key,
491                        // 'deleted', NULL FROM deleted` already fans out to one row
492                        // per id the `DELETE ... RETURNING key` actually removed, so
493                        // widening the predicate to `id = ANY($1)` is enough — an id
494                        // that doesn't exist contributes no row to `deleted` and so
495                        // no audit row either, exactly like the loop it replaces.
496                        //
497                        // The returned count matches the *ids submitted*, not rows
498                        // actually deleted, exactly like the loop this replaces
499                        // (which incremented its counter once per id regardless of
500                        // whether that id matched a row).
501                        let mut conn = pool
502                            .get()
503                            .await
504                            .map_err(|e| AdminError::Database(e.to_string()))?;
505                        diesel::sql_query(
506                            "WITH deleted AS ( \
507                                 DELETE FROM autumn_feature_flags WHERE id = ANY($1) RETURNING key \
508                             ), \
509                             _audit AS ( \
510                                 INSERT INTO feature_flag_changes (key, mutation, actor) \
511                                 SELECT key, 'deleted', NULL FROM deleted \
512                             ) \
513                             SELECT COUNT(*) AS count FROM deleted",
514                        )
515                        .bind::<diesel::sql_types::Array<diesel::sql_types::BigInt>, _>(&ids)
516                        .get_result::<CountRow>(&mut conn)
517                        .await
518                        .map_err(|e| AdminError::Database(e.to_string()))?;
519                        Ok(u64::try_from(ids.len()).unwrap_or(u64::MAX))
520                    }},
521                    sqlite => {{
522                        let _ = (&pool, &ids);
523                        crate::traits::require_postgres("FeatureFlagAdminModel").map(|()| 0)
524                    }},
525                }
526            });
527        }
528
529        crate::traits::dispatch_restore_purge_or_unhandled(self, pool, action, ids)
530    }
531
532    fn has_history(&self) -> bool {
533        true
534    }
535
536    fn get_history<'a>(
537        &'a self,
538        pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
539        record_id: i64,
540        page: u64,
541        per_page: u64,
542    ) -> crate::AdminFuture<'a, crate::AdminHistoryPage> {
543        use diesel::prelude::*;
544        use diesel_async::RunQueryDsl;
545
546        let pool = pool.clone();
547        Box::pin(async move {
548            crate::traits::require_postgres("FeatureFlagAdminModel")?;
549            let mut conn = pool
550                .get()
551                .await
552                .map_err(|e| AdminError::Database(e.to_string()))?;
553
554            // Resolve the flag key by its stable integer id.
555            let key: Option<String> =
556                diesel::sql_query("SELECT key FROM autumn_feature_flags WHERE id = $1")
557                    .bind::<diesel::sql_types::BigInt, _>(record_id)
558                    .get_result::<KeyRow>(&mut conn)
559                    .await
560                    .optional()
561                    .unwrap_or(None)
562                    .map(|r| r.key);
563
564            let Some(key) = key else {
565                return Ok(crate::AdminHistoryPage {
566                    entries: vec![],
567                    total: 0,
568                    page,
569                    per_page,
570                });
571            };
572
573            // Follow the full rename ancestry, not just one level.  Each rename
574            // writes (new_key, 'renamed_from=old_key') into feature_flag_changes;
575            // the recursive CTE walks those breadcrumbs until no more predecessors
576            // are found, so an a→b→c chain shows all three keys' histories.
577            let ancestor_cte = "WITH RECURSIVE ancestors AS ( \
578                SELECT $1::text AS key \
579                UNION \
580                SELECT regexp_replace(ffc.mutation, '^renamed_from=', '') \
581                FROM feature_flag_changes ffc \
582                JOIN ancestors a ON ffc.key = a.key \
583                WHERE ffc.mutation LIKE 'renamed_from=%' \
584            )";
585
586            let count: i64 = diesel::sql_query(format!(
587                "{ancestor_cte} \
588                 SELECT COUNT(*) FROM feature_flag_changes \
589                 WHERE key IN (SELECT key FROM ancestors)",
590            ))
591            .bind::<diesel::sql_types::Text, _>(&key)
592            .get_result::<CountRow>(&mut conn)
593            .await
594            .map_or(0, |r| r.count);
595
596            let offset = (page.saturating_sub(1)) * per_page;
597            let entries: Vec<crate::AdminHistoryEntry> = diesel::sql_query(format!(
598                "{ancestor_cte} \
599                 SELECT id, mutation AS op, actor, changed_at \
600                 FROM feature_flag_changes \
601                 WHERE key IN (SELECT key FROM ancestors) \
602                 ORDER BY changed_at DESC \
603                 LIMIT $2 OFFSET $3",
604            ))
605            .bind::<diesel::sql_types::Text, _>(&key)
606            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(per_page).unwrap_or(i64::MAX))
607            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
608            .load::<HistoryRow>(&mut conn)
609            .await
610            .unwrap_or_default()
611            .into_iter()
612            .map(|r| crate::AdminHistoryEntry {
613                id: r.id,
614                actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
615                op: r.op,
616                request_id: None,
617                changes: vec![],
618                recorded_at: r.changed_at.and_utc(),
619            })
620            .collect();
621
622            Ok(crate::AdminHistoryPage {
623                entries,
624                total: u64::try_from(count).unwrap_or(0),
625                page,
626                per_page,
627            })
628        })
629    }
630}
631
632// ── Helpers ───────────────────────────────────────────────────────────────────
633
634/// Parse `raw` as a JSON array of strings and re-serialise to canonical form.
635///
636/// An empty string is treated as `[]`.  A trailing comma (`["a",]`), a
637/// non-array value, or mixed element types are all rejected with a validation
638/// error so bad data never reaches the database column that later casts to
639/// `::jsonb`.
640fn validate_string_array(raw: &str, field: &str) -> Result<String, AdminError> {
641    let trimmed = raw.trim();
642    if trimmed.is_empty() {
643        return Ok("[]".to_owned());
644    }
645    match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
646        Ok(arr) if arr.iter().all(serde_json::Value::is_string) => {
647            Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
648        }
649        Ok(_) => Err(AdminError::Validation(format!(
650            "'{field}' must be a JSON array of strings (e.g. [\"user:42\"])"
651        ))),
652        Err(_) => Err(AdminError::Validation(format!(
653            "'{field}' must be valid JSON (e.g. [\"user:42\"]); check for trailing commas"
654        ))),
655    }
656}
657
658// ── Row types ─────────────────────────────────────────────────────────────────
659
660#[derive(diesel::QueryableByName)]
661struct CountRow {
662    #[diesel(sql_type = diesel::sql_types::BigInt)]
663    count: i64,
664}
665
666#[derive(diesel::QueryableByName)]
667struct KeyRow {
668    #[diesel(sql_type = diesel::sql_types::Text)]
669    key: String,
670}
671
672#[derive(diesel::QueryableByName)]
673struct FlagRow {
674    #[diesel(sql_type = diesel::sql_types::BigInt)]
675    id: i64,
676    #[diesel(sql_type = diesel::sql_types::Text)]
677    key: String,
678    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
679    description: Option<String>,
680    #[diesel(sql_type = diesel::sql_types::Bool)]
681    enabled: bool,
682    #[diesel(sql_type = diesel::sql_types::SmallInt)]
683    rollout_pct: i16,
684    #[diesel(sql_type = diesel::sql_types::Text)]
685    actor_allowlist: String,
686    #[diesel(sql_type = diesel::sql_types::Text)]
687    group_allowlist: String,
688    #[diesel(sql_type = diesel::sql_types::Timestamp)]
689    updated_at: chrono::NaiveDateTime,
690}
691
692impl FlagRow {
693    fn into_json(self) -> Value {
694        serde_json::json!({
695            "id": self.id,
696            "key": self.key,
697            "description": self.description,
698            "enabled": self.enabled,
699            "rollout_pct": self.rollout_pct,
700            "actor_allowlist": self.actor_allowlist,
701            "group_allowlist": self.group_allowlist,
702            "updated_at": self.updated_at.and_utc().to_rfc3339(),
703        })
704    }
705}
706
707#[derive(diesel::QueryableByName)]
708struct HistoryRow {
709    #[diesel(sql_type = diesel::sql_types::BigInt)]
710    id: i64,
711    #[diesel(sql_type = diesel::sql_types::Text)]
712    op: String,
713    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
714    actor: Option<String>,
715    #[diesel(sql_type = diesel::sql_types::Timestamp)]
716    changed_at: chrono::NaiveDateTime,
717}
718
719// ── Tests ─────────────────────────────────────────────────────────────────────
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn feature_flag_admin_model_slug_is_feature_flags() {
727        let model = FeatureFlagAdminModel;
728        assert_eq!(model.slug(), "feature-flags");
729    }
730
731    #[test]
732    fn feature_flag_admin_model_has_correct_display_names() {
733        let model = FeatureFlagAdminModel;
734        assert_eq!(model.display_name(), "Feature Flag");
735        assert_eq!(model.display_name_plural(), "Feature Flags");
736    }
737
738    #[test]
739    fn feature_flag_admin_fields_include_required_columns() {
740        let model = FeatureFlagAdminModel;
741        let fields = model.fields();
742        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
743        assert!(names.contains(&"key"), "must have key field");
744        assert!(names.contains(&"enabled"), "must have enabled field");
745        assert!(
746            names.contains(&"rollout_pct"),
747            "must have rollout_pct field"
748        );
749        assert!(
750            names.contains(&"actor_allowlist"),
751            "must have actor_allowlist field"
752        );
753    }
754
755    #[test]
756    fn feature_flag_admin_model_has_history() {
757        let model = FeatureFlagAdminModel;
758        assert!(
759            model.has_history(),
760            "feature flag admin must expose history"
761        );
762    }
763
764    #[test]
765    fn record_display_uses_flag_key() {
766        let model = FeatureFlagAdminModel;
767        let record = serde_json::json!({"key": "beta_inbox", "enabled": false});
768        assert_eq!(model.record_display(&record), "Flag: beta_inbox");
769    }
770
771    #[test]
772    fn record_display_fallback_when_no_key() {
773        let model = FeatureFlagAdminModel;
774        let record = serde_json::json!({});
775        assert_eq!(model.record_display(&record), "Feature Flag");
776    }
777
778    #[test]
779    fn globally_enabled_with_zero_rollout_promotes_to_100() {
780        // When the admin checks "Globally Enabled" but leaves Rollout % at the
781        // default 0%, the saved rollout_pct must be 100 so the evaluator
782        // (which requires rollout_pct >= 100 for global access) works correctly.
783        //
784        // This is a pure logic test — it doesn't hit the DB; it just verifies
785        // that the promotion happens before the SQL bind.
786        let enabled = true;
787        let submitted_rollout: i64 = 0;
788        let mut rollout_pct = submitted_rollout.clamp(0, 100);
789        if enabled && rollout_pct == 0 {
790            rollout_pct = 100;
791        }
792        assert_eq!(
793            rollout_pct, 100,
794            "enabled=true + rollout=0 must be promoted to rollout=100"
795        );
796    }
797
798    #[test]
799    fn globally_enabled_with_explicit_rollout_is_preserved() {
800        // If the admin explicitly sets 25% rollout AND checks "Globally Enabled",
801        // the rollout should stay at 25 (not promoted to 100).
802        let enabled = true;
803        let submitted_rollout: i64 = 25;
804        let mut rollout_pct = submitted_rollout.clamp(0, 100);
805        if enabled && rollout_pct == 0 {
806            rollout_pct = 100;
807        }
808        assert_eq!(
809            rollout_pct, 25,
810            "enabled=true + explicit rollout=25 must be preserved"
811        );
812    }
813
814    #[test]
815    fn disabled_with_zero_rollout_is_not_promoted() {
816        // Kill-switch (enabled=false) with rollout=0 must stay at 0.
817        let enabled = false;
818        let submitted_rollout: i64 = 0;
819        let mut rollout_pct = submitted_rollout.clamp(0, 100);
820        let has_allowlist = false;
821        if enabled && rollout_pct == 0 && !has_allowlist {
822            rollout_pct = 100;
823        }
824        assert_eq!(rollout_pct, 0, "kill-switch must not promote rollout_pct");
825    }
826
827    #[test]
828    fn enabled_with_zero_rollout_and_non_empty_allowlist_is_not_promoted() {
829        // When the admin creates an allowlist-only flag (enabled=true, rollout=0%,
830        // actor_allowlist non-empty), rollout_pct must NOT be promoted to 100 —
831        // that would expose the flag to everyone instead of just listed actors.
832        let enabled = true;
833        let submitted_rollout: i64 = 0;
834        let actor_allowlist = r#"["user:42"]"#;
835        let group_allowlist = "[]";
836        let mut rollout_pct = submitted_rollout.clamp(0, 100);
837        let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
838        if enabled && rollout_pct == 0 && !has_allowlist {
839            rollout_pct = 100;
840        }
841        assert_eq!(
842            rollout_pct, 0,
843            "allowlist-only flag must not have rollout_pct promoted to 100"
844        );
845    }
846
847    #[test]
848    fn validate_string_array_accepts_valid_array() {
849        let result = validate_string_array(r#"["user:1","user:2"]"#, "actor_allowlist");
850        assert!(result.is_ok(), "valid array must be accepted: {result:?}");
851    }
852
853    #[test]
854    fn validate_string_array_accepts_empty_string_as_empty_array() {
855        let result = validate_string_array("", "actor_allowlist");
856        assert_eq!(result.unwrap(), "[]");
857    }
858
859    #[test]
860    fn validate_string_array_rejects_trailing_comma() {
861        let result = validate_string_array(r#"["user:42",]"#, "actor_allowlist");
862        assert!(result.is_err(), "trailing comma must be rejected");
863    }
864
865    #[test]
866    fn validate_string_array_rejects_non_array() {
867        let result = validate_string_array("user:42", "actor_allowlist");
868        assert!(result.is_err(), "bare string must be rejected");
869    }
870
871    #[test]
872    fn validate_string_array_rejects_array_with_non_string_elements() {
873        let result = validate_string_array("[1, 2, 3]", "actor_allowlist");
874        assert!(result.is_err(), "integer elements must be rejected");
875    }
876
877    #[test]
878    fn validate_string_array_normalises_output() {
879        // Re-serialisation removes extra whitespace and produces canonical JSON.
880        let result = validate_string_array(r#"[ "user:1" ,  "user:2" ]"#, "actor_allowlist");
881        assert_eq!(result.unwrap(), r#"["user:1","user:2"]"#);
882    }
883
884    // ── execute_action fallthrough (restore/purge/unhandled) ──────────
885    //
886    // FeatureFlagAdminModel never supports soft delete, so these three
887    // branches always error on the first id without touching the pool —
888    // characterizing them pins the exact error text ahead of routing them
889    // through the shared trait helper (Echo merge with TokenAdminModel).
890
891    fn dummy_pool()
892    -> diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection> {
893        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
894        use diesel_async::pooled_connection::deadpool::Pool;
895        let mgr = AsyncDieselConnectionManager::<::autumn_web::RuntimeConnection>::new(
896            "postgresql://test",
897        );
898        Pool::builder(mgr).build().expect("build pool")
899    }
900
901    #[tokio::test]
902    async fn execute_action_restore_errors_soft_delete_unsupported() {
903        let model = FeatureFlagAdminModel;
904        let pool = dummy_pool();
905        let err = model
906            .execute_action(&pool, "restore", vec![1, 2])
907            .await
908            .expect_err("restore must fail: model does not support soft delete");
909        assert!(
910            matches!(err, AdminError::Other(_)),
911            "must be AdminError::Other: {err:?}"
912        );
913        assert!(format!("{err:?}").contains("does not support soft delete"));
914    }
915
916    #[tokio::test]
917    async fn execute_action_purge_errors_soft_delete_unsupported() {
918        let model = FeatureFlagAdminModel;
919        let pool = dummy_pool();
920        let err = model
921            .execute_action(&pool, "purge", vec![1])
922            .await
923            .expect_err("purge must fail: model does not support soft delete");
924        assert!(
925            matches!(err, AdminError::Other(_)),
926            "must be AdminError::Other: {err:?}"
927        );
928        assert!(format!("{err:?}").contains("does not support soft delete"));
929    }
930
931    #[tokio::test]
932    async fn execute_action_unhandled_action_errors_with_action_name() {
933        let model = FeatureFlagAdminModel;
934        let pool = dummy_pool();
935        let err = model
936            .execute_action(&pool, "archive", vec![1])
937            .await
938            .expect_err("unknown action must error");
939        assert!(
940            matches!(err, AdminError::Other(_)),
941            "must be AdminError::Other: {err:?}"
942        );
943        assert!(format!("{err:?}").contains("unhandled bulk action 'archive'"));
944    }
945}