max / makenotwork
26 files changed,
+741 insertions,
-245 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.5.14" | |
| 3 | + | version = "0.5.15" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | v0.5.12 deployed 2026-05-10. Audit grade A (Run 24). ~88K LOC, 1,933 tests, 0 warnings. Migration 110. Sprints 1-9 complete (see `todo_done.md`). Content seeded: AF 0.4.0 + GO 0.3.1 on discover page. | |
| 4 | + | v0.5.14 deployed 2026-05-11. Audit grade A (Run 24). ~88K LOC, 1,935 tests, 0 warnings. Migration 111. Sprints 1-9 complete (see `todo_done.md`). Content seeded: AF 0.4.0 + GO 0.3.1 on discover page. | |
| 5 | 5 | ||
| 6 | 6 | Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |
| 7 | 7 | ||
| @@ -11,7 +11,7 @@ Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |||
| 11 | 11 | ||
| 12 | 12 | Priority order. See `human_todo.md` for the full manual testing feature map. | |
| 13 | 13 | ||
| 14 | - | 1. ~~**Deploy**~~ — Done (v0.5.11, 2026-05-10). Run 24 fixes + scheduler SQL fixes + robots.txt + Prometheus auth + ALERT_EMAIL. | |
| 14 | + | 1. ~~**Deploy**~~ — Done (v0.5.14, 2026-05-11). Run 24 fixes + scheduler SQL fixes + robots.txt + Prometheus auth + ALERT_EMAIL + tag taxonomy overhaul (migration 111). | |
| 15 | 15 | 2. **Manual testing** — walk through `human_todo.md` sign-off table on live server (Stripe checkout, license keys, promo codes, cart, SyncKit sync) | |
| 16 | 16 | 3. **Content seeding** — at least one real creator with published content on discover page | |
| 17 | 17 | 4. **Invite testers** — generate invite codes, send hand-written emails per `docs/internal/outreach/tiers.md` | |
| @@ -195,7 +195,7 @@ MNW/server/src/ | |||
| 195 | 195 | import/ (CSV converter, pipeline, intermediate format) | |
| 196 | 196 | MNW/server/tests/ | |
| 197 | 197 | integration.rs, harness/, workflows/*.rs | |
| 198 | - | MNW/server/migrations/ (001-107) | |
| 198 | + | MNW/server/migrations/ (001-111) | |
| 199 | 199 | MNW/server/templates/ | |
| 200 | 200 | MNW/server/deploy/ | |
| 201 | 201 | MNW/server/site-docs/public/, MNW/server/site-docs/unpublished/ |
| @@ -166,6 +166,31 @@ pub async fn update_app_sync_sub_period<'e>( | |||
| 166 | 166 | Ok(()) | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | + | /// Update the tier and storage limit of an app sync subscription. | |
| 170 | + | #[tracing::instrument(skip_all)] | |
| 171 | + | pub async fn update_app_sync_sub_tier<'e>( | |
| 172 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 173 | + | stripe_subscription_id: &str, | |
| 174 | + | tier: AppSyncTier, | |
| 175 | + | storage_limit_bytes: Option<i64>, | |
| 176 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 177 | + | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 178 | + | r#" | |
| 179 | + | UPDATE app_sync_subscriptions | |
| 180 | + | SET tier = $2, storage_limit_bytes = $3 | |
| 181 | + | WHERE stripe_subscription_id = $1 | |
| 182 | + | RETURNING * | |
| 183 | + | "#, | |
| 184 | + | ) | |
| 185 | + | .bind(stripe_subscription_id) | |
| 186 | + | .bind(tier) | |
| 187 | + | .bind(storage_limit_bytes) | |
| 188 | + | .fetch_optional(executor) | |
| 189 | + | .await?; | |
| 190 | + | ||
| 191 | + | Ok(sub) | |
| 192 | + | } | |
| 193 | + | ||
| 169 | 194 | /// Cancel an app sync subscription (set status + canceled_at). | |
| 170 | 195 | #[tracing::instrument(skip_all)] | |
| 171 | 196 | pub async fn cancel_app_sync_sub( |
| @@ -103,12 +103,13 @@ fn append_item_discover_filters( | |||
| 103 | 103 | query.push_str(" AND i.price_cents <= $4"); | |
| 104 | 104 | } | |
| 105 | 105 | if filters.tag.is_some() { | |
| 106 | + | // Match exact slug or any descendant (e.g. "audio.genre" matches "audio.genre.electronic") | |
| 106 | 107 | query.push_str( | |
| 107 | 108 | r#" AND EXISTS ( | |
| 108 | 109 | SELECT 1 FROM item_tags it2 | |
| 109 | 110 | JOIN tags t2 ON t2.id = it2.tag_id | |
| 110 | 111 | WHERE it2.item_id = i.id | |
| 111 | - | AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5)) | |
| 112 | + | AND (t2.slug = $5 OR t2.path LIKE $5 || '.%') | |
| 112 | 113 | )"#, | |
| 113 | 114 | ); | |
| 114 | 115 | } |
| @@ -30,12 +30,15 @@ pub struct DbCategoryCount { | |||
| 30 | 30 | // ── Tag models ── | |
| 31 | 31 | ||
| 32 | 32 | /// A tag in the hierarchical taxonomy. | |
| 33 | + | /// | |
| 34 | + | /// Tag slugs use dot-notation (`audio.genre.electronic`) and are validated | |
| 35 | + | /// by tagtree, not the general `Slug` type. | |
| 33 | 36 | #[derive(Debug, Clone, FromRow)] | |
| 34 | 37 | #[allow(dead_code)] // Fields populated by sqlx query, read during type conversion | |
| 35 | 38 | pub struct DbTag { | |
| 36 | 39 | pub id: TagId, | |
| 37 | 40 | pub name: String, | |
| 38 | - | pub slug: Slug, | |
| 41 | + | pub slug: String, | |
| 39 | 42 | pub parent_id: Option<TagId>, | |
| 40 | 43 | pub sort_order: i32, | |
| 41 | 44 | pub created_at: DateTime<Utc>, | |
| @@ -50,7 +53,7 @@ pub struct DbItemTag { | |||
| 50 | 53 | pub tag_id: TagId, | |
| 51 | 54 | pub is_primary: bool, | |
| 52 | 55 | pub tag_name: String, | |
| 53 | - | pub tag_slug: Slug, | |
| 56 | + | pub tag_slug: String, | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | /// Tag with item count, used for discover sidebar facets. | |
| @@ -59,6 +62,6 @@ pub struct DbItemTag { | |||
| 59 | 62 | pub struct DbTagCount { | |
| 60 | 63 | pub tag_id: TagId, | |
| 61 | 64 | pub tag_name: String, | |
| 62 | - | pub tag_slug: Slug, | |
| 65 | + | pub tag_slug: String, | |
| 63 | 66 | pub count: i64, | |
| 64 | 67 | } |
| @@ -3,7 +3,6 @@ | |||
| 3 | 3 | use sqlx::PgPool; | |
| 4 | 4 | ||
| 5 | 5 | use super::models::*; | |
| 6 | - | use super::validated_types::Slug; | |
| 7 | 6 | use super::{ItemId, TagId}; | |
| 8 | 7 | use crate::error::Result; | |
| 9 | 8 | ||
| @@ -73,33 +72,45 @@ pub async fn get_tags_for_items( | |||
| 73 | 72 | Ok(map) | |
| 74 | 73 | } | |
| 75 | 74 | ||
| 76 | - | /// Find an existing tag by name (case-insensitive) or create a new one. | |
| 75 | + | /// Find an existing tag by slug or create a new one. | |
| 76 | + | /// | |
| 77 | + | /// Slugs use dot-notation (e.g. `audio.genre.electronic`). The `path` column | |
| 78 | + | /// is set to the slug, and `parent_id` is resolved from the parent slug | |
| 79 | + | /// (everything before the last dot). | |
| 77 | 80 | #[tracing::instrument(skip_all)] | |
| 78 | - | pub async fn find_or_create_tag(pool: &PgPool, name: &str) -> Result<DbTag> { | |
| 79 | - | // Try to find existing tag first | |
| 81 | + | pub async fn find_or_create_tag(pool: &PgPool, name: &str, slug: &str) -> Result<DbTag> { | |
| 82 | + | // Try to find existing tag by slug first | |
| 80 | 83 | if let Some(tag) = sqlx::query_as::<_, DbTag>( | |
| 81 | - | "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE lower(name) = lower($1)", | |
| 84 | + | "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE slug = $1", | |
| 82 | 85 | ) | |
| 83 | - | .bind(name) | |
| 86 | + | .bind(slug) | |
| 84 | 87 | .fetch_optional(pool) | |
| 85 | 88 | .await? | |
| 86 | 89 | { | |
| 87 | 90 | return Ok(tag); | |
| 88 | 91 | } | |
| 89 | 92 | ||
| 90 | - | // Create new tag with slug derived from name | |
| 91 | - | let slug = Slug::new(&name.to_lowercase().replace(' ', "-")) | |
| 92 | - | .unwrap_or_else(|_| Slug::new("tag").expect("hardcoded slug")); | |
| 93 | + | // Resolve parent from slug hierarchy (e.g. "audio.genre.electronic" → parent "audio.genre") | |
| 94 | + | let parent_id: Option<TagId> = if let Some(parent_slug) = tagtree::parent(slug) { | |
| 95 | + | sqlx::query_scalar("SELECT id FROM tags WHERE slug = $1") | |
| 96 | + | .bind(parent_slug) | |
| 97 | + | .fetch_optional(pool) | |
| 98 | + | .await? | |
| 99 | + | } else { | |
| 100 | + | None | |
| 101 | + | }; | |
| 102 | + | ||
| 93 | 103 | let tag = sqlx::query_as::<_, DbTag>( | |
| 94 | 104 | r#" | |
| 95 | - | INSERT INTO tags (name, slug, path) | |
| 96 | - | VALUES ($1, $2, $1) | |
| 105 | + | INSERT INTO tags (name, slug, parent_id, path) | |
| 106 | + | VALUES ($1, $2, $3, $2) | |
| 97 | 107 | ON CONFLICT (slug) DO UPDATE SET name = tags.name | |
| 98 | 108 | RETURNING id, name, slug, parent_id, sort_order, created_at, path | |
| 99 | 109 | "#, | |
| 100 | 110 | ) | |
| 101 | 111 | .bind(name) | |
| 102 | - | .bind(&slug) | |
| 112 | + | .bind(slug) | |
| 113 | + | .bind(parent_id) | |
| 103 | 114 | .fetch_one(pool) | |
| 104 | 115 | .await?; | |
| 105 | 116 | ||
| @@ -222,9 +233,12 @@ pub async fn get_tag_counts( | |||
| 222 | 233 | Ok(counts) | |
| 223 | 234 | } | |
| 224 | 235 | ||
| 225 | - | /// Get a tag by its URL slug. | |
| 236 | + | /// Get a tag by its URL slug (dot-notation, e.g. `audio.genre.electronic`). | |
| 237 | + | /// | |
| 238 | + | /// Takes `&str` rather than `&Slug` because tag slugs use dot-notation | |
| 239 | + | /// (validated by tagtree), not the general slug format. | |
| 226 | 240 | #[tracing::instrument(skip_all)] | |
| 227 | - | pub async fn get_tag_by_slug(pool: &PgPool, slug: &Slug) -> Result<Option<DbTag>> { | |
| 241 | + | pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbTag>> { | |
| 228 | 242 | let tag = sqlx::query_as::<_, DbTag>( | |
| 229 | 243 | "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE slug = $1", | |
| 230 | 244 | ) | |
| @@ -311,10 +325,23 @@ pub async fn get_all_tag_counts(pool: &PgPool) -> Result<std::collections::HashM | |||
| 311 | 325 | Ok(rows.into_iter().collect()) | |
| 312 | 326 | } | |
| 313 | 327 | ||
| 314 | - | /// Suggest up to 5 tags for an item based on its metadata. | |
| 328 | + | /// Map item type to tag type prefix for suggestions. | |
| 329 | + | fn item_type_to_tag_prefix(item_type: &str) -> &str { | |
| 330 | + | match item_type { | |
| 331 | + | "audio" | "sample" | "preset" => "audio", | |
| 332 | + | "plugin" | "template" | "digital" => "software", | |
| 333 | + | "text" => "writing", | |
| 334 | + | "video" | "course" => "video", | |
| 335 | + | "image" => "visual", | |
| 336 | + | _ => "", | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /// Suggest up to 5 tags for an item based on its type and metadata. | |
| 315 | 341 | /// | |
| 316 | - | /// Matching priority: exact type match, parent-type match, substring in title/description, | |
| 317 | - | /// trigram similarity. Excludes tags already on the item. | |
| 342 | + | /// Matching priority: tags under the item's type prefix, then substring/trigram | |
| 343 | + | /// similarity against title and description. Only suggests leaf tags (depth >= 3). | |
| 344 | + | /// Excludes tags already on the item. | |
| 318 | 345 | #[tracing::instrument(skip_all)] | |
| 319 | 346 | pub async fn suggest_tags_for_item( | |
| 320 | 347 | pool: &PgPool, | |
| @@ -324,28 +351,28 @@ pub async fn suggest_tags_for_item( | |||
| 324 | 351 | item_type: &str, | |
| 325 | 352 | ) -> Result<Vec<DbTag>> { | |
| 326 | 353 | let text = format!("{} {}", title, description.unwrap_or("")); | |
| 354 | + | let type_prefix = item_type_to_tag_prefix(item_type); | |
| 355 | + | let prefix_pattern = format!("{type_prefix}.%"); | |
| 327 | 356 | ||
| 328 | 357 | let tags = sqlx::query_as::<_, DbTag>( | |
| 329 | 358 | r#" | |
| 330 | 359 | SELECT t.id, t.name, t.slug, t.parent_id, t.sort_order, t.created_at, t.path | |
| 331 | 360 | FROM tags t | |
| 332 | 361 | WHERE t.id NOT IN (SELECT tag_id FROM item_tags WHERE item_id = $1) | |
| 362 | + | AND length(t.path) - length(replace(t.path, '.', '')) >= 2 | |
| 333 | 363 | AND ( | |
| 334 | - | t.slug = $2 | |
| 335 | - | OR EXISTS(SELECT 1 FROM tags p WHERE p.id = t.parent_id AND p.slug = $2) | |
| 364 | + | t.path LIKE $2 | |
| 336 | 365 | OR $3 ILIKE '%' || replace(replace(replace(t.name, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 337 | 366 | OR similarity(t.name, $3) > 0.3 | |
| 338 | 367 | ) | |
| 339 | 368 | ORDER BY | |
| 340 | - | (t.slug = $2) DESC, | |
| 341 | - | EXISTS(SELECT 1 FROM tags p WHERE p.id = t.parent_id AND p.slug = $2) DESC, | |
| 342 | - | ($3 ILIKE '%' || replace(replace(replace(t.name, '\', '\\'), '%', '\%'), '_', '\_') || '%') DESC, | |
| 369 | + | (t.path LIKE $2) DESC, | |
| 343 | 370 | similarity(t.name, $3) DESC | |
| 344 | 371 | LIMIT 5 | |
| 345 | 372 | "#, | |
| 346 | 373 | ) | |
| 347 | 374 | .bind(item_id) | |
| 348 | - | .bind(item_type) | |
| 375 | + | .bind(&prefix_pattern) | |
| 349 | 376 | .bind(&text) | |
| 350 | 377 | .fetch_all(pool) | |
| 351 | 378 | .await?; |
| @@ -197,10 +197,9 @@ async fn import_item( | |||
| 197 | 197 | .await?; | |
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | - | // Attach tags by name lookup | |
| 201 | - | for tag_name in &item.tags { | |
| 202 | - | let slug = crate::helpers::slugify(tag_name); | |
| 203 | - | if let Ok(Some(tag)) = db::tags::get_tag_by_slug(pool, &slug).await { | |
| 200 | + | // Attach tags by slug lookup (dot-notation, e.g. "audio.genre.electronic") | |
| 201 | + | for tag_slug in &item.tags { | |
| 202 | + | if let Ok(Some(tag)) = db::tags::get_tag_by_slug(pool, tag_slug).await { | |
| 204 | 203 | let _ = db::tags::add_tag_to_item(pool, db_item.id, tag.id, false).await; | |
| 205 | 204 | } | |
| 206 | 205 | } |
| @@ -253,6 +253,81 @@ impl StripeClient { | |||
| 253 | 253 | Ok(()) | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | + | /// Change the tier of a platform-level app sync subscription. | |
| 257 | + | /// | |
| 258 | + | /// Retrieves the subscription to find the existing item, then updates it | |
| 259 | + | /// with new inline price_data for the target tier. Stripe prorates automatically. | |
| 260 | + | /// Returns the updated subscription's metadata for webhook reconciliation. | |
| 261 | + | #[tracing::instrument(skip_all, name = "payments::update_app_sync_subscription_tier")] | |
| 262 | + | pub async fn update_app_sync_subscription_tier( | |
| 263 | + | &self, | |
| 264 | + | stripe_sub_id: &str, | |
| 265 | + | product_name: &str, | |
| 266 | + | price_cents: i64, | |
| 267 | + | interval: &str, | |
| 268 | + | ) -> Result<()> { | |
| 269 | + | let http = reqwest::Client::new(); | |
| 270 | + | let auth = format!("Bearer {}", self.config.secret_key); | |
| 271 | + | ||
| 272 | + | // 1. Retrieve subscription to get the current item ID | |
| 273 | + | let resp = http | |
| 274 | + | .get(&format!("https://api.stripe.com/v1/subscriptions/{stripe_sub_id}")) | |
| 275 | + | .header("Authorization", &auth) | |
| 276 | + | .timeout(std::time::Duration::from_secs(30)) | |
| 277 | + | .send() | |
| 278 | + | .await | |
| 279 | + | .map_err(|e| { | |
| 280 | + | tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to retrieve subscription"); | |
| 281 | + | AppError::Internal(anyhow::anyhow!("Failed to retrieve subscription")) | |
| 282 | + | })?; | |
| 283 | + | ||
| 284 | + | if !resp.status().is_success() { | |
| 285 | + | let body = resp.text().await.unwrap_or_default(); | |
| 286 | + | tracing::error!(stripe_sub_id = %stripe_sub_id, body = %body, "Stripe retrieve subscription returned error"); | |
| 287 | + | return Err(AppError::Internal(anyhow::anyhow!("Failed to retrieve subscription"))); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | let sub_json: serde_json::Value = resp.json().await.map_err(|e| { | |
| 291 | + | tracing::error!(error = ?e, "failed to parse subscription JSON"); | |
| 292 | + | AppError::Internal(anyhow::anyhow!("Failed to parse subscription")) | |
| 293 | + | })?; | |
| 294 | + | ||
| 295 | + | let item_id = sub_json["items"]["data"][0]["id"] | |
| 296 | + | .as_str() | |
| 297 | + | .ok_or_else(|| { | |
| 298 | + | tracing::error!(stripe_sub_id = %stripe_sub_id, "subscription has no items"); | |
| 299 | + | AppError::Internal(anyhow::anyhow!("Subscription has no items")) | |
| 300 | + | })?; | |
| 301 | + | ||
| 302 | + | // 2. Update subscription: replace the item's price with new inline price_data | |
| 303 | + | let resp = http | |
| 304 | + | .post(&format!("https://api.stripe.com/v1/subscriptions/{stripe_sub_id}")) | |
| 305 | + | .header("Authorization", &auth) | |
| 306 | + | .form(&[ | |
| 307 | + | ("items[0][id]", item_id), | |
| 308 | + | ("items[0][price_data][currency]", "usd"), | |
| 309 | + | ("items[0][price_data][product_data][name]", product_name), | |
| 310 | + | ("items[0][price_data][unit_amount]", &price_cents.to_string()), | |
| 311 | + | ("items[0][price_data][recurring][interval]", interval), | |
| 312 | + | ("proration_behavior", "create_prorations"), | |
| 313 | + | ]) | |
| 314 | + | .timeout(std::time::Duration::from_secs(30)) | |
| 315 | + | .send() | |
| 316 | + | .await | |
| 317 | + | .map_err(|e| { | |
| 318 | + | tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to update subscription tier"); | |
| 319 | + | AppError::Internal(anyhow::anyhow!("Failed to update subscription tier")) | |
| 320 | + | })?; | |
| 321 | + | ||
| 322 | + | if !resp.status().is_success() { | |
| 323 | + | let body = resp.text().await.unwrap_or_default(); | |
| 324 | + | tracing::error!(stripe_sub_id = %stripe_sub_id, body = %body, "Stripe update subscription returned error"); | |
| 325 | + | return Err(AppError::Internal(anyhow::anyhow!("Failed to update subscription tier"))); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | Ok(()) | |
| 329 | + | } | |
| 330 | + | ||
| 256 | 331 | /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account. | |
| 257 | 332 | #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")] | |
| 258 | 333 | pub async fn cancel_platform_subscription( |
| @@ -80,6 +80,8 @@ pub trait PaymentProvider: Send + Sync { | |||
| 80 | 80 | async fn cancel_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()>; | |
| 81 | 81 | /// Set or clear `cancel_at_period_end` on a fan subscription (for creator pause/resume). | |
| 82 | 82 | async fn set_cancel_at_period_end(&self, stripe_sub_id: &str, connected_account_id: &str, cancel: bool) -> crate::error::Result<()>; | |
| 83 | + | /// Update the tier of an app sync subscription (swap price with proration). | |
| 84 | + | async fn update_app_sync_subscription_tier(&self, stripe_sub_id: &str, product_name: &str, price_cents: i64, interval: &str) -> crate::error::Result<()>; | |
| 83 | 85 | /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account. | |
| 84 | 86 | async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>; | |
| 85 | 87 | ||
| @@ -182,6 +184,10 @@ impl PaymentProvider for StripeClient { | |||
| 182 | 184 | StripeClient::set_cancel_at_period_end(self, stripe_sub_id, connected_account_id, cancel).await | |
| 183 | 185 | } | |
| 184 | 186 | ||
| 187 | + | async fn update_app_sync_subscription_tier(&self, stripe_sub_id: &str, product_name: &str, price_cents: i64, interval: &str) -> crate::error::Result<()> { | |
| 188 | + | StripeClient::update_app_sync_subscription_tier(self, stripe_sub_id, product_name, price_cents, interval).await | |
| 189 | + | } | |
| 190 | + | ||
| 185 | 191 | async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()> { | |
| 186 | 192 | StripeClient::cancel_platform_subscription(self, stripe_sub_id).await | |
| 187 | 193 | } |
| @@ -151,10 +151,11 @@ pub(in crate::routes::api) async fn bulk_price( | |||
| 151 | 151 | pub struct BulkTagRequest { | |
| 152 | 152 | #[serde(default)] | |
| 153 | 153 | pub item_ids: Vec<ItemId>, | |
| 154 | - | pub tag_name: String, | |
| 154 | + | /// Dot-notation tag slug, e.g. "audio.genre.electronic". | |
| 155 | + | pub tag_slug: String, | |
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | - | /// Bulk-add a tag to selected items (creates tag if needed). | |
| 158 | + | /// Bulk-add a tag to selected items by slug lookup. | |
| 158 | 159 | #[tracing::instrument(skip_all, name = "items::bulk_tag")] | |
| 159 | 160 | pub(in crate::routes::api) async fn bulk_tag( | |
| 160 | 161 | State(state): State<AppState>, | |
| @@ -163,18 +164,17 @@ pub(in crate::routes::api) async fn bulk_tag( | |||
| 163 | 164 | ) -> Result<impl IntoResponse> { | |
| 164 | 165 | user.check_not_suspended()?; | |
| 165 | 166 | ||
| 166 | - | let tag_name = req.tag_name.trim().to_lowercase(); | |
| 167 | - | if tag_name.is_empty() || tag_name.len() > 50 { | |
| 168 | - | return Err(AppError::BadRequest("Tag must be 1-50 characters".into())); | |
| 169 | - | } | |
| 167 | + | let slug = req.tag_slug.trim(); | |
| 168 | + | crate::validation::validate_tag_slug(slug)?; | |
| 170 | 169 | ||
| 171 | 170 | let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; | |
| 172 | 171 | ||
| 173 | - | // Find or create the tag | |
| 174 | - | let tag = db::tags::find_or_create_tag(&state.db, &tag_name).await?; | |
| 172 | + | let tag = db::tags::get_tag_by_slug(&state.db, slug) | |
| 173 | + | .await? | |
| 174 | + | .ok_or(AppError::NotFound)?; | |
| 175 | 175 | ||
| 176 | 176 | let count = db::items::bulk_add_tag(&state.db, &req.item_ids, project_id, user.id, tag.id).await?; | |
| 177 | 177 | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 178 | 178 | ||
| 179 | - | Ok(htmx_toast_response(&format!("Tag \"{tag_name}\" added to {count} item(s)"), "success")) | |
| 179 | + | Ok(htmx_toast_response(&format!("Tag \"{}\" added to {count} item(s)", tag.name), "success")) | |
| 180 | 180 | } |
| @@ -24,7 +24,13 @@ pub struct AddTagForm { | |||
| 24 | 24 | pub tag_id: TagId, | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | + | /// Maximum number of tags per item. | |
| 28 | + | const MAX_TAGS_PER_ITEM: usize = 5; | |
| 29 | + | ||
| 27 | 30 | /// Add a tag to an owned item, returning the rendered HTMX tag element. | |
| 31 | + | /// | |
| 32 | + | /// Only leaf tags (depth >= 3, i.e. `type.category.value`) may be assigned. | |
| 33 | + | /// Items are limited to [`MAX_TAGS_PER_ITEM`] tags. | |
| 28 | 34 | #[tracing::instrument(skip_all, name = "items::add_tag")] | |
| 29 | 35 | pub(in crate::routes::api) async fn add_tag( | |
| 30 | 36 | State(state): State<AppState>, | |
| @@ -39,6 +45,21 @@ pub(in crate::routes::api) async fn add_tag( | |||
| 39 | 45 | .await? | |
| 40 | 46 | .ok_or(AppError::NotFound)?; | |
| 41 | 47 | ||
| 48 | + | // Only leaf tags (depth >= 3) are assignable to items | |
| 49 | + | if tagtree::depth(&tag.slug) < 3 { | |
| 50 | + | return Err(AppError::Validation( | |
| 51 | + | "Only leaf tags (type.category.value) can be assigned to items".into(), | |
| 52 | + | )); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Enforce per-item tag limit | |
| 56 | + | let existing = db::tags::get_tags_for_item(&state.db, id).await?; | |
| 57 | + | if existing.len() >= MAX_TAGS_PER_ITEM && !existing.iter().any(|t| t.tag_id == form.tag_id) { | |
| 58 | + | return Err(AppError::Validation( | |
| 59 | + | format!("Items may have at most {MAX_TAGS_PER_ITEM} tags"), | |
| 60 | + | )); | |
| 61 | + | } | |
| 62 | + | ||
| 42 | 63 | db::tags::add_tag_to_item(&state.db, id, form.tag_id, false).await?; | |
| 43 | 64 | db::projects::bump_cache_generation(&state.db, item.project_id).await?; | |
| 44 | 65 |
| @@ -10,8 +10,8 @@ use tower_sessions::Session; | |||
| 10 | 10 | use crate::{ | |
| 11 | 11 | auth::MaybeUser, | |
| 12 | 12 | constants, | |
| 13 | - | db::{self, discover::DiscoverFilters, DiscoverSort, ItemType, Slug}, | |
| 14 | - | error::{AppError, Result}, | |
| 13 | + | db::{self, discover::DiscoverFilters, DiscoverSort, ItemType}, | |
| 14 | + | error::Result, | |
| 15 | 15 | helpers::get_csrf_token, | |
| 16 | 16 | templates::*, | |
| 17 | 17 | types::*, | |
| @@ -226,10 +226,9 @@ pub(super) async fn tag_tree( | |||
| 226 | 226 | ) -> Result<impl IntoResponse> { | |
| 227 | 227 | let csrf_token = get_csrf_token(&session).await; | |
| 228 | 228 | ||
| 229 | - | // Resolve parent tag from ?parent=slug | |
| 229 | + | // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre") | |
| 230 | 230 | let parent_tag = if let Some(ref slug) = query.parent { | |
| 231 | - | let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?; | |
| 232 | - | db::tags::get_tag_by_slug(&state.db, &slug).await? | |
| 231 | + | db::tags::get_tag_by_slug(&state.db, slug).await? | |
| 233 | 232 | } else { | |
| 234 | 233 | None | |
| 235 | 234 | }; |
| @@ -55,7 +55,7 @@ pub(crate) struct SyncAuthResponse { | |||
| 55 | 55 | ||
| 56 | 56 | #[derive(Deserialize, utoipa::ToSchema)] | |
| 57 | 57 | pub(crate) struct ValidateAppQuery { | |
| 58 | - | api_key: String, | |
| 58 | + | pub(crate) api_key: String, | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | #[derive(Serialize, utoipa::ToSchema)] | |
| @@ -301,6 +301,32 @@ pub(super) struct SubscriptionCheckoutRequest { | |||
| 301 | 301 | pub interval: String, | |
| 302 | 302 | } | |
| 303 | 303 | ||
| 304 | + | /// Individual tier info returned by the tiers endpoint. | |
| 305 | + | #[derive(Serialize)] | |
| 306 | + | pub(super) struct TierInfo { | |
| 307 | + | pub id: String, | |
| 308 | + | pub label: String, | |
| 309 | + | pub description: String, | |
| 310 | + | pub storage_bytes: Option<i64>, | |
| 311 | + | pub monthly_price_cents: i64, | |
| 312 | + | pub annual_price_cents: i64, | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /// Response from the app tiers endpoint. | |
| 316 | + | #[derive(Serialize)] | |
| 317 | + | pub(super) struct AppTiersResponse { | |
| 318 | + | pub app_name: String, | |
| 319 | + | pub tiers: Vec<TierInfo>, | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[derive(Deserialize)] | |
| 323 | + | pub(super) struct SubscriptionChangeTierRequest { | |
| 324 | + | /// New tier: "light", "standard", or "large" | |
| 325 | + | pub tier: String, | |
| 326 | + | /// Billing interval: "monthly" or "annual" | |
| 327 | + | pub interval: String, | |
| 328 | + | } | |
| 329 | + | ||
| 304 | 330 | #[derive(Serialize)] | |
| 305 | 331 | pub(super) struct SubscriptionCheckoutResponse { | |
| 306 | 332 | /// Stripe Checkout URL to redirect the user to | |
| @@ -355,6 +381,8 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 355 | 381 | .route("/api/v1/sync/auth", post(auth::sync_auth)) | |
| 356 | 382 | .route("/api/sync/validate-app", post(auth::validate_app)) | |
| 357 | 383 | .route("/api/v1/sync/validate-app", post(auth::validate_app)) | |
| 384 | + | .route("/api/sync/app/tiers", post(subscription::get_app_tiers)) | |
| 385 | + | .route("/api/v1/sync/app/tiers", post(subscription::get_app_tiers)) | |
| 358 | 386 | .route_layer(GovernorLayer { | |
| 359 | 387 | config: auth_rate_limit, | |
| 360 | 388 | }); | |
| @@ -401,6 +429,8 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 401 | 429 | .route("/api/v1/sync/subscription", get(subscription::get_subscription_status)) | |
| 402 | 430 | .route("/api/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 403 | 431 | .route("/api/v1/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 432 | + | .route("/api/sync/subscription/change", post(subscription::change_tier)) | |
| 433 | + | .route("/api/v1/sync/subscription/change", post(subscription::change_tier)) | |
| 404 | 434 | // Per-app rate limit (inner layer runs first): prevents one developer's | |
| 405 | 435 | // app from starving other apps. Extracts app ID from JWT payload. | |
| 406 | 436 | .route_layer(GovernorLayer { |
| @@ -14,7 +14,7 @@ use crate::{ | |||
| 14 | 14 | AppState, | |
| 15 | 15 | }; | |
| 16 | 16 | ||
| 17 | - | use super::{SubscriptionCheckoutRequest, SubscriptionCheckoutResponse, SubscriptionStatusResponse}; | |
| 17 | + | use super::{AppTiersResponse, SubscriptionChangeTierRequest, SubscriptionCheckoutRequest, SubscriptionCheckoutResponse, SubscriptionStatusResponse, TierInfo, ValidateAppQuery}; | |
| 18 | 18 | ||
| 19 | 19 | /// Billing interval for inline price_data. | |
| 20 | 20 | #[derive(Debug, Clone, Copy, PartialEq)] | |
| @@ -23,6 +23,45 @@ enum BillingInterval { | |||
| 23 | 23 | Annual, | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | /// POST /api/v1/sync/app/tiers — return available pricing tiers for an app. | |
| 27 | + | /// | |
| 28 | + | /// Authenticated by API key only (no JWT required). Allows clients to display | |
| 29 | + | /// pricing before the user has logged in. | |
| 30 | + | #[tracing::instrument(skip_all, name = "synckit::get_app_tiers")] | |
| 31 | + | pub(super) async fn get_app_tiers( | |
| 32 | + | State(state): State<AppState>, | |
| 33 | + | Json(params): Json<ValidateAppQuery>, | |
| 34 | + | ) -> Result<impl IntoResponse> { | |
| 35 | + | let app = db::synckit::get_sync_app_by_api_key(&state.db, ¶ms.api_key) | |
| 36 | + | .await? | |
| 37 | + | .ok_or(AppError::Unauthorized)?; | |
| 38 | + | ||
| 39 | + | let all_tiers = [AppSyncTier::Light, AppSyncTier::Standard, AppSyncTier::Large]; | |
| 40 | + | let tiers: Vec<TierInfo> = all_tiers | |
| 41 | + | .iter() | |
| 42 | + | .filter_map(|tier| { | |
| 43 | + | let monthly = tier.monthly_price_cents(&app.name)?; | |
| 44 | + | let annual = tier.annual_price_cents(&app.name)?; | |
| 45 | + | Some(TierInfo { | |
| 46 | + | id: tier.to_string(), | |
| 47 | + | label: tier.label().to_string(), | |
| 48 | + | description: match tier.blob_storage_bytes() { | |
| 49 | + | Some(bytes) => format!("{} GB storage", bytes / (1024 * 1024 * 1024)), | |
| 50 | + | None => "Cloud sync".to_string(), | |
| 51 | + | }, | |
| 52 | + | storage_bytes: tier.blob_storage_bytes(), | |
| 53 | + | monthly_price_cents: monthly, | |
| 54 | + | annual_price_cents: annual, | |
| 55 | + | }) | |
| 56 | + | }) | |
| 57 | + | .collect(); | |
| 58 | + | ||
| 59 | + | Ok(Json(AppTiersResponse { | |
| 60 | + | app_name: app.name, | |
| 61 | + | tiers, | |
| 62 | + | })) | |
| 63 | + | } | |
| 64 | + | ||
| 26 | 65 | /// GET /api/v1/sync/subscription — check subscription status for this user+app. | |
| 27 | 66 | #[tracing::instrument(skip_all, name = "synckit::get_subscription_status")] | |
| 28 | 67 | pub(super) async fn get_subscription_status( | |
| @@ -128,3 +167,85 @@ pub(super) async fn create_checkout( | |||
| 128 | 167 | ||
| 129 | 168 | Ok(Json(SubscriptionCheckoutResponse { checkout_url })) | |
| 130 | 169 | } | |
| 170 | + | ||
| 171 | + | /// POST /api/v1/sync/subscription/change — change tier on an existing subscription. | |
| 172 | + | /// | |
| 173 | + | /// Validates the new tier, calls Stripe to update the subscription item with | |
| 174 | + | /// new inline pricing (prorated), and updates the local DB record. | |
| 175 | + | #[tracing::instrument(skip_all, name = "synckit::change_subscription_tier")] | |
| 176 | + | pub(super) async fn change_tier( | |
| 177 | + | State(state): State<AppState>, | |
| 178 | + | sync_user: SyncUser, | |
| 179 | + | Json(req): Json<SubscriptionChangeTierRequest>, | |
| 180 | + | ) -> Result<impl IntoResponse> { | |
| 181 | + | let new_tier: AppSyncTier = req.tier.parse() | |
| 182 | + | .map_err(|_| AppError::BadRequest("Invalid tier. Expected: standard, light, or large".to_string()))?; | |
| 183 | + | ||
| 184 | + | let interval = match req.interval.as_str() { | |
| 185 | + | "monthly" => "month", | |
| 186 | + | "annual" => "year", | |
| 187 | + | _ => return Err(AppError::BadRequest("Invalid interval. Expected: monthly or annual".to_string())), | |
| 188 | + | }; | |
| 189 | + | ||
| 190 | + | // Must have an active subscription to change tier | |
| 191 | + | let sub = db::app_sync::get_app_sync_sub(&state.db, sync_user.user_id, sync_user.app_id) | |
| 192 | + | .await? | |
| 193 | + | .filter(|s| s.status == db::SubscriptionStatus::Active) | |
| 194 | + | .ok_or_else(|| AppError::BadRequest("No active subscription to change".to_string()))?; | |
| 195 | + | ||
| 196 | + | if sub.tier == new_tier { | |
| 197 | + | return Err(AppError::BadRequest("Already on this tier".to_string())); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | let app = db::synckit::get_sync_app_by_id(&state.db, sync_user.app_id) | |
| 201 | + | .await? | |
| 202 | + | .ok_or(AppError::Unauthorized)?; | |
| 203 | + | ||
| 204 | + | let price_cents = match interval { | |
| 205 | + | "year" => new_tier.annual_price_cents(&app.name), | |
| 206 | + | _ => new_tier.monthly_price_cents(&app.name), | |
| 207 | + | } | |
| 208 | + | .ok_or_else(|| AppError::BadRequest(format!("Tier '{}' is not available for {}", new_tier, app.name)))?; | |
| 209 | + | ||
| 210 | + | let product_name = new_tier.product_name(&app.name); | |
| 211 | + | ||
| 212 | + | let stripe = state.stripe.as_ref() | |
| 213 | + | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 214 | + | ||
| 215 | + | // Update the subscription on Stripe (prorated) | |
| 216 | + | stripe.update_app_sync_subscription_tier( | |
| 217 | + | &sub.stripe_subscription_id, | |
| 218 | + | &product_name, | |
| 219 | + | price_cents, | |
| 220 | + | interval, | |
| 221 | + | ).await?; | |
| 222 | + | ||
| 223 | + | // Update local DB immediately (webhook will also fire, but this gives instant feedback) | |
| 224 | + | let storage_limit_bytes = new_tier.blob_storage_bytes(); | |
| 225 | + | db::app_sync::update_app_sync_sub_tier( | |
| 226 | + | &state.db, | |
| 227 | + | &sub.stripe_subscription_id, | |
| 228 | + | new_tier, | |
| 229 | + | storage_limit_bytes, | |
| 230 | + | ).await?; | |
| 231 | + | ||
| 232 | + | tracing::info!( | |
| 233 | + | user_id = %sync_user.user_id, app_id = %sync_user.app_id, | |
| 234 | + | old_tier = %sub.tier, new_tier = %new_tier, | |
| 235 | + | "app sync subscription tier changed" | |
| 236 | + | ); | |
| 237 | + | ||
| 238 | + | // Return updated status | |
| 239 | + | let storage_used = db::synckit::get_blob_storage_used( | |
| 240 | + | &state.db, sync_user.app_id, sync_user.user_id, | |
| 241 | + | ).await.unwrap_or(0); | |
| 242 | + | ||
| 243 | + | Ok(Json(SubscriptionStatusResponse { | |
| 244 | + | active: true, | |
| 245 | + | tier: Some(new_tier.to_string()), | |
| 246 | + | status: Some("active".to_string()), | |
| 247 | + | storage_limit_bytes, | |
| 248 | + | storage_used_bytes: Some(storage_used), | |
| 249 | + | current_period_end: sub.current_period_end, | |
| 250 | + | })) | |
| 251 | + | } |
| @@ -79,12 +79,13 @@ pub fn validate_tag_name(name: &str) -> Result<(), AppError> { | |||
| 79 | 79 | ||
| 80 | 80 | /// Validate a tag slug using the tagtree standard. | |
| 81 | 81 | /// | |
| 82 | - | /// Tag slugs support dot-separated hierarchy (e.g. "software.plugin") with a | |
| 83 | - | /// maximum depth of 5 and maximum length of 100 characters. | |
| 84 | - | const MNW_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { | |
| 82 | + | /// Tag slugs follow a 3-level hierarchy: `type.category.value` | |
| 83 | + | /// (e.g. `audio.genre.electronic`, `software.language.rust`). | |
| 84 | + | /// `semantic_depth: 2` enforces at least 3 segments. | |
| 85 | + | pub const MNW_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { | |
| 85 | 86 | max_depth: 5, | |
| 86 | 87 | max_length: 100, | |
| 87 | - | semantic_depth: 0, | |
| 88 | + | semantic_depth: 2, | |
| 88 | 89 | }; | |
| 89 | 90 | ||
| 90 | 91 | pub fn validate_tag_slug(slug: &str) -> Result<(), AppError> { |
| @@ -48,13 +48,13 @@ | |||
| 48 | 48 | <div id="bulk-tag-form" style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 4px;"> | |
| 49 | 49 | <div style="display: flex; gap: 0.75rem; align-items: end;"> | |
| 50 | 50 | <div> | |
| 51 | - | <label style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">Tag name</label> | |
| 52 | - | <input type="text" id="bulk-tag-input" placeholder="e.g. ambient" style="width: 200px; padding: 0.3rem;"> | |
| 51 | + | <label style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">Tag slug</label> | |
| 52 | + | <input type="text" id="bulk-tag-input" placeholder="e.g. audio.genre.ambient" style="width: 260px; padding: 0.3rem;"> | |
| 53 | 53 | </div> | |
| 54 | 54 | <button class="primary small" onclick="submitBulkTag()">Apply</button> | |
| 55 | 55 | <button class="secondary small" onclick="document.getElementById('bulk-tag-form').style.display='none'">Cancel</button> | |
| 56 | 56 | </div> | |
| 57 | - | <p class="form-hint" style="margin-top: 0.5rem;">Creates the tag if it doesn't exist.</p> | |
| 57 | + | <p class="form-hint" style="margin-top: 0.5rem;">Use dot-notation: type.category.value</p> | |
| 58 | 58 | </div> | |
| 59 | 59 | <table class="data-table"> | |
| 60 | 60 | <thead> | |
| @@ -311,11 +311,11 @@ function submitBulkTag() { | |||
| 311 | 311 | var checked = document.querySelectorAll('.bulk-check:checked'); | |
| 312 | 312 | if (checked.length === 0) return; | |
| 313 | 313 | var tag = document.getElementById('bulk-tag-input').value.trim(); | |
| 314 | - | if (!tag) { showToast('Enter a tag name'); return; } | |
| 314 | + | if (!tag) { showToast('Enter a tag slug'); return; } | |
| 315 | 315 | ||
| 316 | 316 | var params = new URLSearchParams(); | |
| 317 | 317 | for (var i = 0; i < checked.length; i++) params.append('item_ids', checked[i].value); | |
| 318 | - | params.append('tag_name', tag); | |
| 318 | + | params.append('tag_slug', tag); | |
| 319 | 319 | ||
| 320 | 320 | fetch('/api/items/bulk/tag', { | |
| 321 | 321 | method: 'POST', |
| @@ -238,7 +238,8 @@ function updateFeatures(projectId) { | |||
| 238 | 238 | }).then(function(r) { | |
| 239 | 239 | if (r.ok) { | |
| 240 | 240 | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; } | |
| 241 | - | setTimeout(function() { if (status) status.textContent = ''; }, 2000); | |
| 241 | + | // Reload the page so the tab bar reflects new features | |
| 242 | + | setTimeout(function() { window.location.reload(); }, 300); | |
| 242 | 243 | } else { | |
| 243 | 244 | if (status) { status.textContent = 'Save failed'; status.style.color = 'var(--error-color)'; } | |
| 244 | 245 | } |
| @@ -58,9 +58,8 @@ | |||
| 58 | 58 | style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Set</button> | |
| 59 | 59 | </td> | |
| 60 | 60 | <td> | |
| 61 | - | <code style="font-size: 0.8rem;">{{ app.api_key_masked }}</code> | |
| 62 | - | <button class="btn-small secondary" onclick="syncKitCopyKey(event, '{{ app.api_key_full }}')" | |
| 63 | - | style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Copy</button> | |
| 61 | + | <code id="synckit-key-display-{{ app.id }}" style="font-size: 0.8rem;">{{ app.api_key_masked }}</code> | |
| 62 | + | <span class="form-hint" style="font-size: 0.7rem; margin-left: 0.25rem;">(use Regenerate to get a new key)</span> | |
| 64 | 63 | </td> | |
| 65 | 64 | <td> | |
| 66 | 65 | {% if app.is_active %} | |
| @@ -89,15 +88,6 @@ | |||
| 89 | 88 | </div> | |
| 90 | 89 | ||
| 91 | 90 | <script> | |
| 92 | - | function syncKitCopyKey(event, key) { | |
| 93 | - | navigator.clipboard.writeText(key).then(function() { | |
| 94 | - | var btn = event.target; | |
| 95 | - | var orig = btn.textContent; | |
| 96 | - | btn.textContent = 'Copied'; | |
| 97 | - | setTimeout(function() { btn.textContent = orig; }, 1500); | |
| 98 | - | }); | |
| 99 | - | } | |
| 100 | - | ||
| 101 | 91 | function syncKitRegenKey(appId) { | |
| 102 | 92 | if (!confirm('Regenerate API key? All existing clients using the current key will stop working.')) return; | |
| 103 | 93 | fetch('/api/sync/apps/' + appId + '/regenerate-key', { | |
| @@ -106,7 +96,18 @@ function syncKitRegenKey(appId) { | |||
| 106 | 96 | headers: csrfHeaders() | |
| 107 | 97 | }).then(function(res) { | |
| 108 | 98 | if (res.ok) { | |
| 109 | - | document.getElementById('tab-synckit').click(); | |
| 99 | + | return res.json().then(function(data) { | |
| 100 | + | // Show the new key with a copy button | |
| 101 | + | var display = document.getElementById('synckit-key-display-' + appId); | |
| 102 | + | if (display && data.api_key) { | |
| 103 | + | display.parentElement.innerHTML = | |
| 104 | + | '<code style="font-size: 0.8rem; word-break: break-all;">' + data.api_key + '</code>' + | |
| 105 | + | ' <button class="btn-small secondary" style="padding: 0.15rem 0.4rem; font-size: 0.75rem;" ' + | |
| 106 | + | 'onclick="navigator.clipboard.writeText(\'' + data.api_key + '\').then(function(){this.textContent=\'Copied\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))">Copy</button>' + | |
| 107 | + | ' <span class="form-hint" style="font-size: 0.7rem; color: var(--error-color);">Save this key now — it cannot be shown again.</span>'; | |
| 108 | + | } | |
| 109 | + | showToast('Key regenerated. Copy it now — it will not be shown again.'); | |
| 110 | + | }); | |
| 110 | 111 | } else { | |
| 111 | 112 | showToast('Failed to regenerate key.'); | |
| 112 | 113 | } | |
| @@ -192,7 +193,22 @@ function syncKitShowSlugForm(appId, currentSlug) { | |||
| 192 | 193 | body: JSON.stringify({ name: name, project_id: '{{ project_id }}' }) | |
| 193 | 194 | }).then(function(res) { | |
| 194 | 195 | if (res.ok) { | |
| 195 | - | document.getElementById('tab-synckit').click(); | |
| 196 | + | return res.json().then(function(data) { | |
| 197 | + | var statusEl = document.getElementById('synckit-create-status'); | |
| 198 | + | if (statusEl && data.api_key) { | |
| 199 | + | statusEl.style.color = ''; | |
| 200 | + | statusEl.style.marginTop = '0.5rem'; | |
| 201 | + | statusEl.innerHTML = | |
| 202 | + | '<div style="padding: 0.75rem; background: var(--light-background); border: 1px solid var(--border-color); margin-top: 0.5rem;">' + | |
| 203 | + | '<p style="margin: 0 0 0.5rem 0; font-weight: 500; color: var(--error-color);">Save this API key now — it cannot be shown again.</p>' + | |
| 204 | + | '<code style="font-size: 0.85rem; word-break: break-all; user-select: all;">' + data.api_key + '</code>' + | |
| 205 | + | ' <button class="btn-small secondary" style="padding: 0.15rem 0.4rem; font-size: 0.75rem;" ' + | |
| 206 | + | 'onclick="navigator.clipboard.writeText(\'' + data.api_key + '\').then(function(){this.textContent=\'Copied\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))">Copy</button>' + | |
| 207 | + | '<button class="btn-small" style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;" ' + | |
| 208 | + | 'onclick="document.getElementById(\'tab-synckit\').click()">Done</button>' + | |
| 209 | + | '</div>'; | |
| 210 | + | } | |
| 211 | + | }); | |
| 196 | 212 | } else { | |
| 197 | 213 | return res.text().then(function(t) { | |
| 198 | 214 | var statusEl = document.getElementById('synckit-create-status'); |
| @@ -81,9 +81,8 @@ | |||
| 81 | 81 | style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Link</button> | |
| 82 | 82 | </td> | |
| 83 | 83 | <td> | |
| 84 | - | <code style="font-size: 0.8rem;">{{ app.api_key_masked }}</code> | |
| 85 | - | <button class="btn-small secondary" onclick="syncKitCopyKey(event, '{{ app.api_key_full }}')" | |
| 86 | - | style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Copy</button> | |
| 84 | + | <code id="synckit-key-display-{{ app.id }}" style="font-size: 0.8rem;">{{ app.api_key_masked }}</code> | |
| 85 | + | <span class="form-hint" style="font-size: 0.7rem; margin-left: 0.25rem;">(use Regenerate to get a new key)</span> | |
| 87 | 86 | </td> | |
| 88 | 87 | <td> | |
| 89 | 88 | {% if app.is_active %} |
| @@ -10,7 +10,7 @@ async fn add_custom_domain() { | |||
| 10 | 10 | ||
| 11 | 11 | let resp = h | |
| 12 | 12 | .client | |
| 13 | - | .post_json("/api/domains", r#"{"domain": "mysite.example.com"}"#) | |
| 13 | + | .post_form("/api/domains", "domain=mysite.example.com") | |
| 14 | 14 | .await; | |
| 15 | 15 | assert!( | |
| 16 | 16 | resp.status.is_success(), | |
| @@ -19,11 +19,12 @@ async fn add_custom_domain() { | |||
| 19 | 19 | resp.text | |
| 20 | 20 | ); | |
| 21 | 21 | ||
| 22 | - | let body: Value = resp.json(); | |
| 23 | - | assert_eq!(body["domain"].as_str().unwrap(), "mysite.example.com"); | |
| 24 | - | assert!(!body["verified"].as_bool().unwrap()); | |
| 25 | - | assert!(body["verification_token"].as_str().unwrap().starts_with("mnw-verify-")); | |
| 26 | - | assert!(body["instructions"].as_str().unwrap().contains("_mnw-verify.mysite.example.com")); | |
| 22 | + | // Handler returns HTML with DNS verification instructions | |
| 23 | + | assert!( | |
| 24 | + | resp.text.contains("_mnw-verify.mysite.example.com"), | |
| 25 | + | "Response should contain DNS verification instructions: {}", | |
| 26 | + | resp.text | |
| 27 | + | ); | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | #[tokio::test] | |
| @@ -33,7 +34,7 @@ async fn add_domain_rejects_duplicate() { | |||
| 33 | 34 | ||
| 34 | 35 | let resp = h | |
| 35 | 36 | .client | |
| 36 | - | .post_json("/api/domains", r#"{"domain": "dup.example.com"}"#) | |
| 37 | + | .post_form("/api/domains", "domain=dup.example.com") | |
| 37 | 38 | .await; | |
| 38 | 39 | assert!(resp.status.is_success()); | |
| 39 | 40 | ||
| @@ -43,7 +44,7 @@ async fn add_domain_rejects_duplicate() { | |||
| 43 | 44 | ||
| 44 | 45 | let resp = h | |
| 45 | 46 | .client | |
| 46 | - | .post_json("/api/domains", r#"{"domain": "dup.example.com"}"#) | |
| 47 | + | .post_form("/api/domains", "domain=dup.example.com") | |
| 47 | 48 | .await; | |
| 48 | 49 | assert!( | |
| 49 | 50 | !resp.status.is_success(), | |
| @@ -60,14 +61,14 @@ async fn add_domain_one_per_user_limit() { | |||
| 60 | 61 | ||
| 61 | 62 | let resp = h | |
| 62 | 63 | .client | |
| 63 | - | .post_json("/api/domains", r#"{"domain": "first.example.com"}"#) | |
| 64 | + | .post_form("/api/domains", "domain=first.example.com") | |
| 64 | 65 | .await; | |
| 65 | 66 | assert!(resp.status.is_success()); | |
| 66 | 67 | ||
| 67 | 68 | // Second domain should fail | |
| 68 | 69 | let resp = h | |
| 69 | 70 | .client | |
| 70 | - | .post_json("/api/domains", r#"{"domain": "second.example.com"}"#) | |
| 71 | + | .post_form("/api/domains", "domain=second.example.com") | |
| 71 | 72 | .await; | |
| 72 | 73 | assert!( | |
| 73 | 74 | !resp.status.is_success(), | |
| @@ -94,7 +95,7 @@ async fn get_domain_returns_domain() { | |||
| 94 | 95 | let _uid = h.create_creator("domgetok").await; | |
| 95 | 96 | ||
| 96 | 97 | h.client | |
| 97 | - | .post_json("/api/domains", r#"{"domain": "getme.example.com"}"#) | |
| 98 | + | .post_form("/api/domains", "domain=getme.example.com") | |
| 98 | 99 | .await; | |
| 99 | 100 | ||
| 100 | 101 | let resp = h.client.get("/api/domains").await; | |
| @@ -110,9 +111,12 @@ async fn remove_domain() { | |||
| 110 | 111 | ||
| 111 | 112 | let resp = h | |
| 112 | 113 | .client | |
| 113 | - | .post_json("/api/domains", r#"{"domain": "remove.example.com"}"#) | |
| 114 | + | .post_form("/api/domains", "domain=remove.example.com") | |
| 114 | 115 | .await; | |
| 115 | 116 | assert!(resp.status.is_success()); | |
| 117 | + | ||
| 118 | + | // GET the domain to retrieve its id (GET returns JSON) | |
| 119 | + | let resp = h.client.get("/api/domains").await; | |
| 116 | 120 | let body: Value = resp.json(); | |
| 117 | 121 | let id = body["id"].as_str().unwrap(); | |
| 118 | 122 | ||
| @@ -335,7 +339,7 @@ async fn add_domain_rejects_mnw_domains() { | |||
| 335 | 339 | ||
| 336 | 340 | let resp = h | |
| 337 | 341 | .client | |
| 338 | - | .post_json("/api/domains", r#"{"domain": "makenot.work"}"#) | |
| 342 | + | .post_form("/api/domains", "domain=makenot.work") | |
| 339 | 343 | .await; | |
| 340 | 344 | assert!( | |
| 341 | 345 | !resp.status.is_success(), | |
| @@ -344,7 +348,7 @@ async fn add_domain_rejects_mnw_domains() { | |||
| 344 | 348 | ||
| 345 | 349 | let resp = h | |
| 346 | 350 | .client | |
| 347 | - | .post_json("/api/domains", r#"{"domain": "sub.makenot.work"}"#) | |
| 351 | + | .post_form("/api/domains", "domain=sub.makenot.work") | |
| 348 | 352 | .await; | |
| 349 | 353 | assert!( | |
| 350 | 354 | !resp.status.is_success(), |