Deleting an app used to require zero scripts and zero domain claims —
practical for empty apps, painful for anything else. Add an opt-in
cascade so the operator can wipe an app in one click while keeping the
safe default for the no-flag case.
Backend: `DELETE /api/v1/admin/apps/{id}?force=true` runs a single
transaction that removes every script in the app (routes and execution
logs cascade via `script_id` FK), then deletes the app row (domains and
slug-history cascade off it). Without `?force=true` the handler still
returns the same `409 HasScripts { script_count }` payload it always did.
Frontend: a new `ConfirmModal.svelte` replaces the bare `window.confirm`
on this page. It's reusable — danger/neutral variants, optional
GitHub-style "type the slug to confirm" gate, ESC/backdrop cancel,
busy state, and a generic body slot — so future destructive actions can
adopt the same pattern instead of growing more browser dialogs. The app
delete confirmation now spells out exactly what disappears (script
count, domain claim list, "all routes & logs") and only enables the red
button once the slug is retyped. The domain-claim delete is also
wired through the modal so this page no longer uses `window.confirm`
anywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
405 lines
14 KiB
Rust
405 lines
14 KiB
Rust
//! CRUD over the `apps` and `app_slug_history` tables.
|
|
//!
|
|
//! Slug validation (regex, reserved-word check) lives in the API
|
|
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
|
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
|
//! that writes the history row in the same transaction.
|
|
|
|
use async_trait::async_trait;
|
|
use picloud_shared::{App, AppId};
|
|
use sqlx::PgPool;
|
|
|
|
use crate::repo::ScriptRepositoryError;
|
|
|
|
/// Result of looking up an app by slug or via the redirect history.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppLookup {
|
|
pub app: App,
|
|
/// `true` when the slug was found in `app_slug_history` rather than
|
|
/// directly on `apps`. Dashboards should issue a redirect.
|
|
pub redirected: bool,
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait AppRepository: Send + Sync {
|
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
|
async fn get_by_slug_or_history(
|
|
&self,
|
|
slug: &str,
|
|
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
|
async fn create(
|
|
&self,
|
|
slug: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<App, ScriptRepositoryError>;
|
|
/// Create that also consumes a matching `app_slug_history` row, if
|
|
/// any. Used after the operator has confirmed they want to break old
|
|
/// redirects.
|
|
async fn create_with_takeover(
|
|
&self,
|
|
slug: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<App, ScriptRepositoryError>;
|
|
async fn update(
|
|
&self,
|
|
id: AppId,
|
|
name: Option<&str>,
|
|
description: Option<Option<&str>>,
|
|
) -> Result<App, ScriptRepositoryError>;
|
|
/// Rename and record the old slug in `app_slug_history` (so
|
|
/// retired URLs keep redirecting). If `take_over_history` is true,
|
|
/// any existing history row for `new_slug` is consumed.
|
|
async fn rename_slug(
|
|
&self,
|
|
id: AppId,
|
|
new_slug: &str,
|
|
take_over_history: bool,
|
|
) -> Result<App, ScriptRepositoryError>;
|
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
|
/// Delete the app along with all its scripts (which in turn cascades
|
|
/// routes and execution logs via their `script_id` FK). Domains and
|
|
/// app-slug-history rows cascade off the app row itself. Runs in a
|
|
/// single transaction so a partial delete cannot be observed.
|
|
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
|
}
|
|
|
|
pub struct PostgresAppRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresAppRepository {
|
|
#[must_use]
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AppRepository for PostgresAppRepository {
|
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
|
let rows = sqlx::query_as::<_, AppRow>(
|
|
"SELECT id, slug, name, description, created_at, updated_at \
|
|
FROM apps ORDER BY name",
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
Ok(rows.into_iter().map(Into::into).collect())
|
|
}
|
|
|
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppRow>(
|
|
"SELECT id, slug, name, description, created_at, updated_at \
|
|
FROM apps WHERE id = $1",
|
|
)
|
|
.bind(id.into_inner())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(Into::into))
|
|
}
|
|
|
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppRow>(
|
|
"SELECT id, slug, name, description, created_at, updated_at \
|
|
FROM apps WHERE slug = $1",
|
|
)
|
|
.bind(slug)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(Into::into))
|
|
}
|
|
|
|
async fn get_by_slug_or_history(
|
|
&self,
|
|
slug: &str,
|
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
|
if let Some(app) = self.get_by_slug(slug).await? {
|
|
return Ok(Some(AppLookup {
|
|
app,
|
|
redirected: false,
|
|
}));
|
|
}
|
|
if let Some(app) = self.slug_in_history(slug).await? {
|
|
return Ok(Some(AppLookup {
|
|
app,
|
|
redirected: true,
|
|
}));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppRow>(
|
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
|
FROM app_slug_history h \
|
|
JOIN apps a ON a.id = h.current_app_id \
|
|
WHERE h.slug = $1",
|
|
)
|
|
.bind(slug)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(Into::into))
|
|
}
|
|
|
|
async fn create(
|
|
&self,
|
|
slug: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
let res = sqlx::query_as::<_, AppRow>(
|
|
"INSERT INTO apps (slug, name, description) \
|
|
VALUES ($1, $2, $3) \
|
|
RETURNING id, slug, name, description, created_at, updated_at",
|
|
)
|
|
.bind(slug)
|
|
.bind(name)
|
|
.bind(description)
|
|
.fetch_one(&self.pool)
|
|
.await;
|
|
|
|
match res {
|
|
Ok(row) => Ok(row.into()),
|
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
|
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
|
),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
async fn create_with_takeover(
|
|
&self,
|
|
slug: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
|
.bind(slug)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let row = sqlx::query_as::<_, AppRow>(
|
|
"INSERT INTO apps (slug, name, description) \
|
|
VALUES ($1, $2, $3) \
|
|
RETURNING id, slug, name, description, created_at, updated_at",
|
|
)
|
|
.bind(slug)
|
|
.bind(name)
|
|
.bind(description)
|
|
.fetch_one(&mut *tx)
|
|
.await;
|
|
let row = match row {
|
|
Ok(r) => r,
|
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
|
return Err(ScriptRepositoryError::Conflict(format!(
|
|
"slug {slug:?} is already in use"
|
|
)));
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
tx.commit().await?;
|
|
Ok(row.into())
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
id: AppId,
|
|
name: Option<&str>,
|
|
description: Option<Option<&str>>,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppRow>(
|
|
"UPDATE apps SET \
|
|
name = COALESCE($2, name), \
|
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
|
updated_at = NOW() \
|
|
WHERE id = $1 \
|
|
RETURNING id, slug, name, description, created_at, updated_at",
|
|
)
|
|
.bind(id.into_inner())
|
|
.bind(name)
|
|
.bind(description.is_some())
|
|
.bind(description.and_then(|d| d))
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
row.map(Into::into)
|
|
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
|
}
|
|
|
|
async fn rename_slug(
|
|
&self,
|
|
id: AppId,
|
|
new_slug: &str,
|
|
take_over_history: bool,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
// 1. Read the current slug (so we can record it in history).
|
|
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
|
.bind(id.into_inner())
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let Some((current_slug,)) = current else {
|
|
return Err(ScriptRepositoryError::Conflict(format!(
|
|
"app {id} not found"
|
|
)));
|
|
};
|
|
|
|
if current_slug == new_slug {
|
|
// No-op rename; just return the row.
|
|
let row = sqlx::query_as::<_, AppRow>(
|
|
"SELECT id, slug, name, description, created_at, updated_at \
|
|
FROM apps WHERE id = $1",
|
|
)
|
|
.bind(id.into_inner())
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
return Ok(row.into());
|
|
}
|
|
|
|
// 2. If renaming back to this app's own retired slug, just
|
|
// consume the history row silently (no warning, no takeover
|
|
// flag required).
|
|
let owns_history: Option<(uuid::Uuid,)> =
|
|
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
|
.bind(new_slug)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
match owns_history {
|
|
Some((owner,)) if owner == id.into_inner() => {
|
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
|
.bind(new_slug)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
Some(_) if take_over_history => {
|
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
|
.bind(new_slug)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
Some(_) => {
|
|
return Err(ScriptRepositoryError::Conflict(format!(
|
|
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
|
)));
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
// 3. Record the current slug in history (replacing any older
|
|
// entry — the same slug can pass through history multiple
|
|
// times across many renames).
|
|
sqlx::query(
|
|
"INSERT INTO app_slug_history (slug, current_app_id) \
|
|
VALUES ($1, $2) \
|
|
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
|
retired_at = NOW()",
|
|
)
|
|
.bind(¤t_slug)
|
|
.bind(id.into_inner())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// 4. Apply the rename. Unique violation = another live app
|
|
// already holds this slug.
|
|
let row = sqlx::query_as::<_, AppRow>(
|
|
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
|
WHERE id = $1 \
|
|
RETURNING id, slug, name, description, created_at, updated_at",
|
|
)
|
|
.bind(id.into_inner())
|
|
.bind(new_slug)
|
|
.fetch_one(&mut *tx)
|
|
.await;
|
|
let row = match row {
|
|
Ok(r) => r,
|
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
|
return Err(ScriptRepositoryError::Conflict(format!(
|
|
"slug {new_slug:?} is already in use by another app"
|
|
)));
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
|
|
tx.commit().await?;
|
|
Ok(row.into())
|
|
}
|
|
|
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
|
.bind(id.into_inner())
|
|
.execute(&self.pool)
|
|
.await;
|
|
match res {
|
|
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
|
"app {id} not found"
|
|
))),
|
|
Ok(_) => Ok(()),
|
|
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
|
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
|
// "has dependents" error rather than a raw SQL message.
|
|
Err(ScriptRepositoryError::Conflict(
|
|
"app still contains scripts; delete or move them first".into(),
|
|
))
|
|
}
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
|
|
.bind(id.into_inner())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
|
.bind(id.into_inner())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if res.rows_affected() == 0 {
|
|
return Err(ScriptRepositoryError::Conflict(format!(
|
|
"app {id} not found"
|
|
)));
|
|
}
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
|
.bind(id.into_inner())
|
|
.fetch_one(&self.pool)
|
|
.await?;
|
|
Ok(count.0)
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct AppRow {
|
|
id: uuid::Uuid,
|
|
slug: String,
|
|
name: String,
|
|
description: Option<String>,
|
|
created_at: chrono::DateTime<chrono::Utc>,
|
|
updated_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
impl From<AppRow> for App {
|
|
fn from(r: AppRow) -> Self {
|
|
Self {
|
|
id: r.id.into(),
|
|
slug: r.slug,
|
|
name: r.name,
|
|
description: r.description,
|
|
created_at: r.created_at,
|
|
updated_at: r.updated_at,
|
|
}
|
|
}
|
|
}
|