max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
29 files changed,
+3247 insertions,
-2437 deletions
| @@ -3508,7 +3508,7 @@ | |||
| 3508 | 3508 | ||
| 3509 | 3509 | [[package]] | |
| 3510 | 3510 | name = "makenotwork" | |
| 3511 | - | version = "0.5.18" | |
| 3511 | + | version = "0.5.19" | |
| 3512 | 3512 | dependencies = [ | |
| 3513 | 3513 | "anyhow", | |
| 3514 | 3514 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.5.18" | |
| 3 | + | version = "0.5.19" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -969,4 +969,156 @@ | |||
| 969 | 969 | let config: Config = toml::from_str(toml).unwrap(); | |
| 970 | 970 | assert_eq!(config.serve.whois_check_interval_secs, 43200); | |
| 971 | 971 | } | |
| 972 | + | ||
| 973 | + | // ───────────────────────────────────────────────────────────────────── | |
| 974 | + | // Defaults-pin tests — every `default_*` constant function is pinned to | |
| 975 | + | // its expected value. Catches `replace fn -> u64 with 0/1` mutations and | |
| 976 | + | // accidental drift when defaults are tweaked. These constants encode | |
| 977 | + | // operational policy (check cadence, retention, etc.) so changes should | |
| 978 | + | // be deliberate. | |
| 979 | + | // ───────────────────────────────────────────────────────────────────── | |
| 980 | + | ||
| 981 | + | #[test] | |
| 982 | + | fn defaults_numeric_intervals() { | |
| 983 | + | assert_eq!(default_peer_heartbeat(), 60, "peer heartbeat = 1 min"); | |
| 984 | + | assert_eq!(default_tls_check_interval(), 3600, "tls = 1 hour"); | |
| 985 | + | assert_eq!(default_route_check_interval(), 300, "routes = 5 min"); | |
| 986 | + | assert_eq!(default_dns_check_interval(), 3600, "dns = 1 hour"); | |
| 987 | + | assert_eq!(default_cors_check_interval(), 3600, "cors = 1 hour"); | |
| 988 | + | assert_eq!(default_whois_check_interval(), 86400, "whois = 24 hours"); | |
| 989 | + | assert_eq!(default_serve_interval(), 300, "serve = 5 min"); | |
| 990 | + | assert_eq!(default_prune_days(), 30, "prune = 30 days"); | |
| 991 | + | } | |
| 992 | + | ||
| 993 | + | #[test] | |
| 994 | + | fn defaults_listen_address() { | |
| 995 | + | assert_eq!(default_listen(), "127.0.0.1:9100"); | |
| 996 | + | } | |
| 997 | + | ||
| 998 | + | #[test] | |
| 999 | + | fn defaults_warn_thresholds() { | |
| 1000 | + | assert_eq!(default_whois_warn_days(), 30, "whois 30 days lead time"); | |
| 1001 | + | assert_eq!(default_tls_warn_days(), 14, "tls 14 days lead time"); | |
| 1002 | + | assert_eq!(default_tls_port(), 443); | |
| 1003 | + | } | |
| 1004 | + | ||
| 1005 | + | #[test] | |
| 1006 | + | fn defaults_cors() { | |
| 1007 | + | assert_eq!(default_cors_method(), "PUT"); | |
| 1008 | + | assert_eq!(default_max_age_hours(), 25, "25h allows cron drift"); | |
| 1009 | + | } | |
| 1010 | + | ||
| 1011 | + | #[test] | |
| 1012 | + | fn defaults_backup() { | |
| 1013 | + | assert_eq!(default_backup_interval(), 3600, "hourly backup check"); | |
| 1014 | + | } | |
| 1015 | + | ||
| 1016 | + | #[test] | |
| 1017 | + | fn defaults_ssh_banner() { | |
| 1018 | + | assert_eq!(default_ssh_banner_port(), 22); | |
| 1019 | + | assert_eq!(default_ssh_banner_timeout(), 5); | |
| 1020 | + | } | |
| 1021 | + | ||
| 1022 | + | #[test] | |
| 1023 | + | fn defaults_latency_baseline() { | |
| 1024 | + | assert_eq!(default_baseline_window_hours(), 168, "7 days"); | |
| 1025 | + | // Spike threshold compares as f64; pin with bit-exact match. | |
| 1026 | + | assert_eq!(default_spike_threshold().to_bits(), 2.0_f64.to_bits()); | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | #[test] | |
| 1030 | + | fn defaults_health_and_test_timeouts() { | |
| 1031 | + | assert_eq!(default_health_timeout(), 10); | |
| 1032 | + | assert_eq!(default_test_timeout(), 600, "10-minute CI suite budget"); | |
| 1033 | + | assert_eq!(default_staleness_days(), 7); | |
| 1034 | + | } | |
| 1035 | + | ||
| 1036 | + | #[test] | |
| 1037 | + | fn defaults_alerts() { | |
| 1038 | + | assert_eq!(default_alert_from(), "PoM Alerts <pom-alerts@makenot.work>"); | |
| 1039 | + | assert_eq!(default_cooldown_secs(), 300, "5-minute alert cooldown"); | |
| 1040 | + | } | |
| 1041 | + | ||
| 1042 | + | // ── Config method tests ── | |
| 1043 | + | ||
| 1044 | + | #[test] | |
| 1045 | + | fn instance_name_returns_configured_value() { | |
| 1046 | + | let toml = r#" | |
| 1047 | + | [serve] | |
| 1048 | + | [instance] | |
| 1049 | + | name = "test-host" | |
| 1050 | + | [targets.x] | |
| 1051 | + | label = "X" | |
| 1052 | + | [targets.x.health] | |
| 1053 | + | url = "https://example.com" | |
| 1054 | + | "#; | |
| 1055 | + | let config: Config = toml::from_str(toml).unwrap(); | |
| 1056 | + | assert_eq!(config.instance_name(), "test-host"); | |
| 1057 | + | } | |
| 1058 | + | ||
| 1059 | + | #[test] | |
| 1060 | + | fn instance_name_falls_back_to_non_empty() { | |
| 1061 | + | // When `name` is None, fall back to hostname or "unknown" — must not be | |
| 1062 | + | // empty regardless. Catches the `instance_name -> String with "xyzzy"` | |
| 1063 | + | // mutant and the empty-string variant. | |
| 1064 | + | let toml = r#" | |
| 1065 | + | [serve] | |
| 1066 | + | [instance] | |
| 1067 | + | [targets.x] | |
| 1068 | + | label = "X" | |
| 1069 | + | [targets.x.health] | |
| 1070 | + | url = "https://example.com" | |
| 1071 | + | "#; | |
| 1072 | + | let config: Config = toml::from_str(toml).unwrap(); | |
| 1073 | + | let name = config.instance_name(); | |
| 1074 | + | assert!(!name.is_empty(), "fallback must produce a non-empty name"); | |
| 1075 | + | // It also must not be the cargo-mutants sentinel. | |
| 1076 | + | assert_ne!(name, "xyzzy"); | |
| 1077 | + | } | |
| 1078 | + | ||
| 1079 | + | #[test] | |
| 1080 | + | fn default_config_path_ends_in_pom_toml() { | |
| 1081 | + | // The exact dir varies per OS, but the suffix is stable. | |
| 1082 | + | // Catches `default_config_path -> Ok(Default::default())` (which would | |
| 1083 | + | // return an empty PathBuf and fail the ends_with check). | |
| 1084 | + | let path = default_config_path().unwrap(); | |
| 1085 | + | assert!( | |
| 1086 | + | path.ends_with("pom/pom.toml") || path.ends_with("pom\\pom.toml"), | |
| 1087 | + | "expected …/pom/pom.toml, got {path:?}" | |
| 1088 | + | ); | |
| 1089 | + | } | |
| 1090 | + | ||
| 1091 | + | #[test] | |
| 1092 | + | fn db_path_ends_in_pom_db() { | |
| 1093 | + | // Same rationale as default_config_path. db_path also has a side | |
| 1094 | + | // effect (creates the parent dir) so we can't easily mock it; the | |
| 1095 | + | // suffix check is the cleanest pin. | |
| 1096 | + | let path = db_path().unwrap(); | |
| 1097 | + | assert!( | |
| 1098 | + | path.ends_with("pom/pom.db") || path.ends_with("pom\\pom.db"), | |
| 1099 | + | "expected …/pom/pom.db, got {path:?}" | |
| 1100 | + | ); | |
| 1101 | + | } | |
| 1102 | + | ||
| 1103 | + | #[test] | |
| 1104 | + | fn config_load_rejects_route_without_leading_slash() { | |
| 1105 | + | // Catches `delete ! in Config::load` (L431): without the `!`, the | |
| 1106 | + | // validator would only reject routes that DO start with '/' — wrong. | |
| 1107 | + | let toml = r#" | |
| 1108 | + | [serve] | |
| 1109 | + | [targets.bad] | |
| 1110 | + | label = "Bad" | |
| 1111 | + | expected_routes = ["no-leading-slash"] | |
| 1112 | + | [targets.bad.health] | |
| 1113 | + | url = "https://example.com" | |
| 1114 | + | "#; | |
| 1115 | + | let tmp = std::env::temp_dir().join(format!("pom_test_{}.toml", std::process::id())); | |
| 1116 | + | std::fs::write(&tmp, toml).unwrap(); | |
| 1117 | + | let result = Config::load(Some(tmp.as_path())); | |
| 1118 | + | let _ = std::fs::remove_file(&tmp); | |
| 1119 | + | assert!( | |
| 1120 | + | matches!(result, Err(PomError::Config(_))), | |
| 1121 | + | "expected Config error rejecting bad route; got {result:?}" | |
| 1122 | + | ); | |
| 1123 | + | } | |
| 972 | 1124 | } |
| @@ -302,10 +302,13 @@ | |||
| 302 | 302 | ||
| 303 | 303 | let pool = PgPool::connect(&database_url).await.expect("Failed to connect"); | |
| 304 | 304 | ||
| 305 | - | // Check that all expected tables exist (must match migrations 001-025) | |
| 305 | + | // Check that all expected tables exist (must match migrations 001-025). | |
| 306 | + | // Note: the session table was renamed from `sessions` to `user_sessions` | |
| 307 | + | // when tower-sessions-sqlx-store config was updated; the old name was left | |
| 308 | + | // in this list and broke the assertion in fresh DBs. | |
| 306 | 309 | let tables = vec![ | |
| 307 | 310 | "users", "projects", "items", "versions", "transactions", | |
| 308 | - | "custom_links", "sessions", "blog_posts", "chapters", | |
| 311 | + | "custom_links", "user_sessions", "blog_posts", "chapters", | |
| 309 | 312 | "creator_waitlist", "creator_waves", "login_tokens", | |
| 310 | 313 | "license_keys", "license_activations", | |
| 311 | 314 | "sync_apps", "sync_devices", "sync_log", "sync_keys", |
| @@ -72,51 +72,6 @@ | |||
| 72 | 72 | Ok(map) | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 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). | |
| 80 | - | #[tracing::instrument(skip_all)] | |
| 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 | |
| 83 | - | if let Some(tag) = sqlx::query_as::<_, DbTag>( | |
| 84 | - | "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE slug = $1", | |
| 85 | - | ) | |
| 86 | - | .bind(slug) | |
| 87 | - | .fetch_optional(pool) | |
| 88 | - | .await? | |
| 89 | - | { | |
| 90 | - | return Ok(tag); | |
| 91 | - | } | |
| 92 | - | ||
| 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 | - | ||
| 103 | - | let tag = sqlx::query_as::<_, DbTag>( | |
| 104 | - | r#" | |
| 105 | - | INSERT INTO tags (name, slug, parent_id, path) | |
| 106 | - | VALUES ($1, $2, $3, $2) | |
| 107 | - | ON CONFLICT (slug) DO UPDATE SET name = tags.name | |
| 108 | - | RETURNING id, name, slug, parent_id, sort_order, created_at, path | |
| 109 | - | "#, | |
| 110 | - | ) | |
| 111 | - | .bind(name) | |
| 112 | - | .bind(slug) | |
| 113 | - | .bind(parent_id) | |
| 114 | - | .fetch_one(pool) | |
| 115 | - | .await?; | |
| 116 | - | ||
| 117 | - | Ok(tag) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | 75 | /// Attach a tag to an item. If `is_primary` is true and the item already has | |
| 121 | 76 | /// a primary tag, the old primary is cleared first (within a transaction). | |
| 122 | 77 | #[tracing::instrument(skip_all)] |
| @@ -659,4 +659,142 @@ | |||
| 659 | 659 | assert!(parse_issue_reply_token("issue+a.b", secret).is_none()); | |
| 660 | 660 | assert!(parse_issue_reply_token("issue+not-uuid.not-uuid.abcd1234abcd1234", secret).is_none()); | |
| 661 | 661 | } | |
| 662 | + | ||
| 663 | + | // ───────────────────────────────────────────────────────────────────── | |
| 664 | + | // Expiry arithmetic tests — pin `now + EXPIRY` so cargo-mutants can't | |
| 665 | + | // replace `+` with `*`/`-` without the test catching it. Each generator | |
| 666 | + | // emits an `expires=` URL parameter; we assert the value is within a | |
| 667 | + | // tight window of `now + EXPIRY`. | |
| 668 | + | // ───────────────────────────────────────────────────────────────────── | |
| 669 | + | ||
| 670 | + | /// Extract the `expires` query param from a URL emitted by a token generator. | |
| 671 | + | fn extract_expires(url: &str) -> i64 { | |
| 672 | + | let parsed: url::Url = url.parse().expect("valid URL"); | |
| 673 | + | parsed | |
| 674 | + | .query_pairs() | |
| 675 | + | .find(|(k, _)| k == "expires") | |
| 676 | + | .expect("expires param") | |
| 677 | + | .1 | |
| 678 | + | .parse() | |
| 679 | + | .expect("expires is i64") | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | /// Assert `actual ∈ [now+expiry, now+expiry + slack]`. The slack covers the | |
| 683 | + | /// few ms between calling `Utc::now()` inside the function and `Utc::now()` | |
| 684 | + | /// here. Any mutation that flips the arithmetic (e.g. `+` → `*`) will | |
| 685 | + | /// produce a value wildly outside this window. | |
| 686 | + | fn assert_within_expiry_window(actual: i64, expiry_secs: i64) { | |
| 687 | + | let now = chrono::Utc::now().timestamp(); | |
| 688 | + | let expected_min = now + expiry_secs - 1; | |
| 689 | + | let expected_max = now + expiry_secs + 5; | |
| 690 | + | assert!( | |
| 691 | + | actual >= expected_min && actual <= expected_max, | |
| 692 | + | "expires={actual} outside [{expected_min}, {expected_max}] (now={now}, expiry_secs={expiry_secs})" | |
| 693 | + | ); | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | #[test] | |
| 697 | + | fn password_reset_url_expires_matches_constant() { | |
| 698 | + | let url = generate_password_reset_url( | |
| 699 | + | "https://example.com", | |
| 700 | + | UserId::new(), | |
| 701 | + | "argon2$dummy", | |
| 702 | + | "secret", | |
| 703 | + | ); | |
| 704 | + | assert_within_expiry_window(extract_expires(&url), constants::PASSWORD_RESET_EXPIRY_SECS); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | #[test] | |
| 708 | + | fn verification_url_expires_matches_constant() { | |
| 709 | + | let url = generate_verification_url( | |
| 710 | + | "https://example.com", | |
| 711 | + | UserId::new(), | |
| 712 | + | "user@example.com", | |
| 713 | + | "secret", | |
| 714 | + | ); | |
| 715 | + | assert_within_expiry_window(extract_expires(&url), constants::EMAIL_VERIFICATION_EXPIRY_SECS); | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | #[test] | |
| 719 | + | fn deletion_url_expires_matches_constant() { | |
| 720 | + | let url = generate_deletion_url( | |
| 721 | + | "https://example.com", | |
| 722 | + | UserId::new(), | |
| 723 | + | "user@example.com", | |
| 724 | + | "secret", | |
| 725 | + | ); | |
| 726 | + | assert_within_expiry_window(extract_expires(&url), constants::ACCOUNT_DELETION_EXPIRY_SECS); | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | // ───────────────────────────────────────────────────────────────────── | |
| 730 | + | // Expiry-comparison boundary tests for `verify_email_signature` and | |
| 731 | + | // `verify_password_reset_signature`. Catches `<` → `==`/`<=` mutations on | |
| 732 | + | // the `if expires < now { return false; }` guard. | |
| 733 | + | // ───────────────────────────────────────────────────────────────────── | |
| 734 | + | ||
| 735 | + | #[test] | |
| 736 | + | fn verify_email_signature_rejects_already_expired() { | |
| 737 | + | // Build a signed URL, then verify with an `expires` value 60s in the past. | |
| 738 | + | // The signature won't match (since the message contains expires) — but | |
| 739 | + | // the early `expires < now` check should fire first and short-circuit. | |
| 740 | + | let user_id = UserId::new(); | |
| 741 | + | let email = "test@example.com"; | |
| 742 | + | let secret = "secret"; | |
| 743 | + | let now = chrono::Utc::now().timestamp(); | |
| 744 | + | ||
| 745 | + | // Generate a sig for an EXPIRED timestamp. | |
| 746 | + | use hmac::{Hmac, Mac}; | |
| 747 | + | use sha2::Sha256; | |
| 748 | + | let expires_past = now - 60; | |
| 749 | + | let message = format!("verify:{}:{}:{}", user_id, expires_past, email); | |
| 750 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); | |
| 751 | + | mac.update(message.as_bytes()); | |
| 752 | + | let sig_past = hex::encode(mac.finalize().into_bytes()); | |
| 753 | + | ||
| 754 | + | // Sig is valid for the message, but expires < now → must reject. | |
| 755 | + | assert!( | |
| 756 | + | !verify_email_signature(user_id, expires_past, email, &sig_past, secret), | |
| 757 | + | "must reject expired signature" | |
| 758 | + | ); | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | #[test] | |
| 762 | + | fn verify_email_signature_accepts_just_in_future() { | |
| 763 | + | // Inverse: a sig that's still valid (expires just in the future) must | |
| 764 | + | // pass — catches `<` → `<=` (which would reject expires == now-1+1). | |
| 765 | + | let user_id = UserId::new(); | |
| 766 | + | let email = "test@example.com"; | |
| 767 | + | let secret = "secret"; | |
| 768 | + | let expires_future = chrono::Utc::now().timestamp() + 3600; | |
| 769 | + | ||
| 770 | + | use hmac::{Hmac, Mac}; | |
| 771 | + | use sha2::Sha256; | |
| 772 | + | let message = format!("verify:{}:{}:{}", user_id, expires_future, email); | |
| 773 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); | |
| 774 | + | mac.update(message.as_bytes()); | |
| 775 | + | let sig = hex::encode(mac.finalize().into_bytes()); | |
| 776 | + | ||
| 777 | + | assert!(verify_email_signature(user_id, expires_future, email, &sig, secret)); | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | #[test] | |
| 781 | + | fn verify_password_reset_signature_rejects_expired() { | |
| 782 | + | // Same boundary check for password reset path (L96 in this file). | |
| 783 | + | let user_id = UserId::new(); | |
| 784 | + | let password_hash = "argon2$dummy"; | |
| 785 | + | let secret = "secret"; | |
| 786 | + | let expires_past = chrono::Utc::now().timestamp() - 60; | |
| 787 | + | ||
| 788 | + | use hmac::{Hmac, Mac}; | |
| 789 | + | use sha2::Sha256; | |
| 790 | + | let message = format!("reset:{}:{}:{}", user_id, expires_past, password_hash); | |
| 791 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); | |
| 792 | + | mac.update(message.as_bytes()); | |
| 793 | + | let sig_past = hex::encode(mac.finalize().into_bytes()); | |
| 794 | + | ||
| 795 | + | assert!( | |
| 796 | + | !verify_password_reset_signature(user_id, expires_past, password_hash, &sig_past, secret), | |
| 797 | + | "must reject expired password reset signature" | |
| 798 | + | ); | |
| 799 | + | } | |
| 662 | 800 | } |
| @@ -333,4 +333,35 @@ | |||
| 333 | 333 | let result = compile_rules_from_dir(dir.path().to_str().unwrap()); | |
| 334 | 334 | assert!(result.is_err()); | |
| 335 | 335 | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn default_namespace_rule_is_unprefixed() { | |
| 339 | + | // Catches the L79 `==` → `!=` mutation. The function emits "rule" for | |
| 340 | + | // default-namespace rules but "ns:rule" otherwise. Under the mutant the | |
| 341 | + | // prefix logic inverts. Existing tests check `detail.contains("rule_x")` | |
| 342 | + | // which is also true for "default:rule_x", so they don't catch the flip. | |
| 343 | + | // Pin the exact format. | |
| 344 | + | let mut compiler = yara_x::Compiler::new(); | |
| 345 | + | compiler | |
| 346 | + | .add_source( | |
| 347 | + | r#" | |
| 348 | + | rule plain { | |
| 349 | + | strings: | |
| 350 | + | $a = "TARGET" | |
| 351 | + | condition: | |
| 352 | + | $a | |
| 353 | + | } | |
| 354 | + | "#, | |
| 355 | + | ) | |
| 356 | + | .unwrap(); | |
| 357 | + | let rules = compiler.build(); | |
| 358 | + | let result = scan_with_yara(&rules, b"TARGET in data"); | |
| 359 | + | let detail = result.detail.unwrap(); | |
| 360 | + | // Default-namespace rule must appear unprefixed. | |
| 361 | + | assert!(detail.contains("plain"), "missing rule name: {detail}"); | |
| 362 | + | assert!( | |
| 363 | + | !detail.contains("default:"), | |
| 364 | + | "default-namespace rules must not be prefixed; got {detail}" | |
| 365 | + | ); | |
| 366 | + | } | |
| 336 | 367 | } |
| @@ -2,10 +2,27 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! These structs are the "view models" that sit between database rows and | |
| 4 | 4 | //! Askama templates. Conversions from `db::Db*` types live in `conversions.rs`. | |
| 5 | + | //! | |
| 6 | + | //! Types are grouped by feature surface in submodules and re-exported flat | |
| 7 | + | //! for ergonomic imports (`use crate::types::*` still works). | |
| 5 | 8 | ||
| 9 | + | mod admin; | |
| 10 | + | mod blog; | |
| 11 | + | mod content; | |
| 6 | 12 | mod conversions; | |
| 13 | + | mod dashboard; | |
| 14 | + | mod discover; | |
| 15 | + | mod payments; | |
| 16 | + | mod user; | |
| 17 | + | ||
| 18 | + | pub use admin::*; | |
| 19 | + | pub use blog::*; | |
| 20 | + | pub use content::*; | |
| 21 | + | pub use dashboard::*; | |
| 22 | + | pub use discover::*; | |
| 23 | + | pub use payments::*; | |
| 24 | + | pub use user::*; | |
| 7 | 25 | ||
| 8 | - | use crate::constants::{DATE_FMT_DATETIME, DATE_FMT_FULL}; | |
| 9 | 26 | use serde::Serialize; | |
| 10 | 27 | ||
| 11 | 28 | /// Envelope for JSON list endpoints: `{"data": [...]}`. | |
| @@ -17,197 +34,6 @@ | |||
| 17 | 34 | pub data: Vec<T>, | |
| 18 | 35 | } | |
| 19 | 36 | ||
| 20 | - | /// User profile data | |
| 21 | - | #[derive(Clone)] | |
| 22 | - | pub struct User { | |
| 23 | - | pub username: String, | |
| 24 | - | pub email: String, | |
| 25 | - | pub display_name: Option<String>, | |
| 26 | - | pub bio: Option<String>, | |
| 27 | - | pub avatar_initials: String, | |
| 28 | - | pub avatar_url: Option<String>, | |
| 29 | - | // Stripe Connect status | |
| 30 | - | pub stripe_connected: bool, | |
| 31 | - | pub stripe_account_id: Option<String>, | |
| 32 | - | pub stripe_onboarding_complete: bool, | |
| 33 | - | pub stripe_payouts_enabled: bool, | |
| 34 | - | pub stripe_charges_enabled: bool, | |
| 35 | - | pub stripe_tax_enabled: bool, | |
| 36 | - | // Notification preferences | |
| 37 | - | pub notify_sale: bool, | |
| 38 | - | pub notify_follower: bool, | |
| 39 | - | pub notify_release: bool, | |
| 40 | - | pub login_notification_enabled: bool, | |
| 41 | - | pub notify_issues: bool, | |
| 42 | - | pub notify_status: bool, | |
| 43 | - | // Tips | |
| 44 | - | pub tips_enabled: bool, | |
| 45 | - | pub notify_tip: bool, | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | impl User { | |
| 49 | - | /// Returns the display name if set, otherwise falls back to the username. | |
| 50 | - | pub fn display_name_or_username(&self) -> &str { | |
| 51 | - | self.display_name.as_deref().filter(|s| !s.is_empty()).unwrap_or(&self.username) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | /// Derive the Stripe connection status from the view fields. | |
| 55 | - | fn stripe_connection_status(&self) -> crate::db::StripeConnectionStatus { | |
| 56 | - | use crate::db::StripeConnectionStatus; | |
| 57 | - | if !self.stripe_connected { | |
| 58 | - | StripeConnectionStatus::NotConnected | |
| 59 | - | } else if !self.stripe_onboarding_complete { | |
| 60 | - | StripeConnectionStatus::Onboarding | |
| 61 | - | } else if !self.stripe_payouts_enabled { | |
| 62 | - | StripeConnectionStatus::PayoutsPending | |
| 63 | - | } else { | |
| 64 | - | StripeConnectionStatus::Active | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | /// Human-readable Stripe Connect status for display in the dashboard. | |
| 69 | - | pub fn stripe_status_text(&self) -> &str { | |
| 70 | - | self.stripe_connection_status().text() | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | /// CSS class for the Stripe status badge (`"active"`, `"pending"`, or `"inactive"`). | |
| 74 | - | pub fn stripe_status_class(&self) -> &str { | |
| 75 | - | self.stripe_connection_status().css_class() | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | /// Display name (or username fallback) escaped for JSON string embedding (JSON-LD). | |
| 79 | - | pub fn display_name_json(&self) -> String { | |
| 80 | - | json_escape(self.display_name_or_username()) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | /// Bio escaped for JSON string embedding (JSON-LD), or empty string if none. | |
| 84 | - | pub fn bio_json(&self) -> String { | |
| 85 | - | self.bio.as_deref().map(json_escape).unwrap_or_default() | |
| 86 | - | } | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | /// A project owned by a user | |
| 90 | - | #[derive(Clone)] | |
| 91 | - | pub struct Project { | |
| 92 | - | pub id: String, | |
| 93 | - | pub slug: String, | |
| 94 | - | pub title: String, | |
| 95 | - | pub description: String, | |
| 96 | - | pub item_count: u32, | |
| 97 | - | pub project_type: String, | |
| 98 | - | pub cover_image_url: Option<String>, | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | impl Project { | |
| 102 | - | /// Title escaped for JSON string embedding (JSON-LD). | |
| 103 | - | pub fn title_json(&self) -> String { | |
| 104 | - | json_escape(&self.title) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | /// Description escaped for JSON string embedding (JSON-LD). | |
| 108 | - | pub fn description_json(&self) -> String { | |
| 109 | - | json_escape(&self.description) | |
| 110 | - | } | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | /// A tag attached to an item, for template display. | |
| 114 | - | #[derive(Clone)] | |
| 115 | - | pub struct TagView { | |
| 116 | - | pub id: String, | |
| 117 | - | pub name: String, | |
| 118 | - | pub slug: String, | |
| 119 | - | pub is_primary: bool, | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | /// Content-type-specific view data for an item. | |
| 123 | - | /// | |
| 124 | - | /// Mirrors `db::ContentData` but with display-ready computed fields | |
| 125 | - | /// (formatted duration strings, rendered HTML, etc.). | |
| 126 | - | #[derive(Clone)] | |
| 127 | - | pub enum ItemContent { | |
| 128 | - | Text { | |
| 129 | - | body: Option<String>, | |
| 130 | - | body_html: Option<String>, | |
| 131 | - | reading_time: Option<String>, | |
| 132 | - | word_count: Option<i32>, | |
| 133 | - | reading_time_minutes: Option<i32>, | |
| 134 | - | }, | |
| 135 | - | Audio { | |
| 136 | - | duration: Option<String>, | |
| 137 | - | duration_seconds: Option<i32>, | |
| 138 | - | cover_url: Option<String>, | |
| 139 | - | episode_number: Option<u32>, | |
| 140 | - | audio_s3_key: Option<String>, | |
| 141 | - | }, | |
| 142 | - | Video { | |
| 143 | - | duration: Option<String>, | |
| 144 | - | duration_seconds: Option<i32>, | |
| 145 | - | cover_url: Option<String>, | |
| 146 | - | video_s3_key: Option<String>, | |
| 147 | - | width: Option<i32>, | |
| 148 | - | height: Option<i32>, | |
| 149 | - | }, | |
| 150 | - | Other, | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | /// An individual item for sale | |
| 154 | - | #[derive(Clone)] | |
| 155 | - | pub struct Item { | |
| 156 | - | pub id: String, | |
| 157 | - | pub title: String, | |
| 158 | - | pub price: String, | |
| 159 | - | /// Raw price in cents for structured data (JSON-LD). | |
| 160 | - | pub price_cents: i32, | |
| 161 | - | pub item_type: String, | |
| 162 | - | pub description: String, | |
| 163 | - | pub thumbnail: String, | |
| 164 | - | pub release_date: String, | |
| 165 | - | pub sales_count: u32, | |
| 166 | - | pub tags: Vec<TagView>, | |
| 167 | - | pub content: ItemContent, | |
| 168 | - | pub cover_image_url: Option<String>, | |
| 169 | - | // Access control | |
| 170 | - | pub is_free: bool, | |
| 171 | - | pub can_access: bool, | |
| 172 | - | // License key settings | |
| 173 | - | pub enable_license_keys: bool, | |
| 174 | - | pub default_max_activations: Option<i32>, | |
| 175 | - | // PWYW | |
| 176 | - | pub pwyw_enabled: bool, | |
| 177 | - | pub pwyw_min_cents: Option<i32>, | |
| 178 | - | // Scheduled publish | |
| 179 | - | pub publish_at: Option<String>, | |
| 180 | - | pub is_public: bool, | |
| 181 | - | // Bundle | |
| 182 | - | pub listed: bool, | |
| 183 | - | pub bundle_item_count: i64, | |
| 184 | - | // License text | |
| 185 | - | pub license_preset: Option<String>, | |
| 186 | - | pub custom_license_text: Option<String>, | |
| 187 | - | // AI tier | |
| 188 | - | pub ai_tier: crate::db::AiTier, | |
| 189 | - | pub ai_disclosure: Option<String>, | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | impl Item { | |
| 193 | - | /// Price formatted as a decimal string for JSON-LD (e.g. "9.99"). | |
| 194 | - | pub fn price_decimal(&self) -> String { | |
| 195 | - | let abs = self.price_cents.unsigned_abs(); | |
| 196 | - | let sign = if self.price_cents < 0 { "-" } else { "" }; | |
| 197 | - | format!("{sign}{}.{:02}", abs / 100, abs % 100) | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | /// Title escaped for JSON string embedding (JSON-LD). | |
| 201 | - | pub fn title_json(&self) -> String { | |
| 202 | - | json_escape(&self.title) | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | /// Description escaped for JSON string embedding (JSON-LD). | |
| 206 | - | pub fn description_json(&self) -> String { | |
| 207 | - | json_escape(&self.description) | |
| 208 | - | } | |
| 209 | - | } | |
| 210 | - | ||
| 211 | 37 | /// Escape a string for safe embedding inside a JSON string value within | |
| 212 | 38 | /// a `<script>` tag. Escapes JSON special chars plus `</` to prevent | |
| 213 | 39 | /// script injection. | |
| @@ -232,660 +58,6 @@ | |||
| 232 | 58 | out | |
| 233 | 59 | } | |
| 234 | 60 | ||
| 235 | - | /// Payout summary derived from the Stripe Balance API. | |
| 236 | - | #[derive(Clone)] | |
| 237 | - | pub struct PayoutSummary { | |
| 238 | - | pub available: String, | |
| 239 | - | pub pending: String, | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | /// A single bar in a revenue bar chart. | |
| 243 | - | #[derive(Clone)] | |
| 244 | - | pub struct ChartBar { | |
| 245 | - | pub label: String, | |
| 246 | - | pub height_pct: f64, | |
| 247 | - | pub value: String, | |
| 248 | - | pub count: i64, | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | /// A tabbed content section within an item, with pre-rendered HTML. | |
| 252 | - | #[derive(Clone)] | |
| 253 | - | pub struct ItemSection { | |
| 254 | - | pub id: String, | |
| 255 | - | pub title: String, | |
| 256 | - | pub slug: String, | |
| 257 | - | pub body: String, | |
| 258 | - | pub body_html: String, | |
| 259 | - | pub sort_order: i32, | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | impl ItemSection { | |
| 263 | - | /// Create from a DB row with media URL resolution for public-facing pages. | |
| 264 | - | pub fn from_db(s: &crate::db::DbItemSection, user_id: crate::db::UserId, cdn_base: &str) -> Self { | |
| 265 | - | ItemSection { | |
| 266 | - | id: s.id.to_string(), | |
| 267 | - | title: s.title.clone(), | |
| 268 | - | slug: s.slug.clone(), | |
| 269 | - | body: s.body.clone(), | |
| 270 | - | body_html: crate::markdown::render_creator_markdown(&s.body, user_id, cdn_base), | |
| 271 | - | sort_order: s.sort_order, | |
| 272 | - | } | |
| 273 | - | } | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | /// A tabbed content section within a project, with pre-rendered HTML. | |
| 277 | - | #[derive(Clone)] | |
| 278 | - | pub struct ProjectSection { | |
| 279 | - | pub id: String, | |
| 280 | - | pub title: String, | |
| 281 | - | pub slug: String, | |
| 282 | - | pub body: String, | |
| 283 | - | pub body_html: String, | |
| 284 | - | pub sort_order: i32, | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | impl ProjectSection { | |
| 288 | - | /// Create from a DB row with media URL resolution for public-facing pages. | |
| 289 | - | pub fn from_db(s: &crate::db::DbProjectSection, user_id: crate::db::UserId, cdn_base: &str) -> Self { | |
| 290 | - | ProjectSection { | |
| 291 | - | id: s.id.to_string(), | |
| 292 | - | title: s.title.clone(), | |
| 293 | - | slug: s.slug.clone(), | |
| 294 | - | body: s.body.clone(), | |
| 295 | - | body_html: crate::markdown::render_creator_markdown(&s.body, user_id, cdn_base), | |
| 296 | - | sort_order: s.sort_order, | |
| 297 | - | } | |
| 298 | - | } | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | /// Chapter/timestamp for audio content | |
| 302 | - | #[derive(Clone)] | |
| 303 | - | pub struct Chapter { | |
| 304 | - | pub title: String, | |
| 305 | - | pub timestamp: String, | |
| 306 | - | pub start_seconds: f64, | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | /// Custom link on a user profile | |
| 310 | - | #[derive(Clone)] | |
| 311 | - | pub struct CustomLink { | |
| 312 | - | pub url: String, | |
| 313 | - | pub title: String, | |
| 314 | - | pub description: String, | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | /// Item in the discover list | |
| 318 | - | #[derive(Clone)] | |
| 319 | - | #[allow(dead_code)] // Fields used by Askama templates | |
| 320 | - | pub struct DiscoverItem { | |
| 321 | - | pub id: String, | |
| 322 | - | pub name: String, | |
| 323 | - | pub creator: String, | |
| 324 | - | pub project: String, | |
| 325 | - | pub item_type: String, | |
| 326 | - | pub primary_tag: String, | |
| 327 | - | pub price: String, | |
| 328 | - | pub is_free: bool, | |
| 329 | - | pub sales: u32, | |
| 330 | - | pub date: String, | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | /// Project in the discover list (projects mode) | |
| 334 | - | #[derive(Clone)] | |
| 335 | - | #[allow(dead_code)] // Fields used by Askama templates | |
| 336 | - | pub struct DiscoverProject { | |
| 337 | - | pub slug: String, | |
| 338 | - | pub title: String, | |
| 339 | - | pub creator: String, | |
| 340 | - | pub project_type: String, | |
| 341 | - | pub description: String, | |
| 342 | - | pub item_count: u32, | |
| 343 | - | pub date: String, | |
| 344 | - | pub category_name: Option<String>, | |
| 345 | - | pub category_slug: Option<String>, | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | /// Filter category for discover page | |
| 349 | - | #[derive(Clone)] | |
| 350 | - | pub struct FilterCategory { | |
| 351 | - | pub name: String, | |
| 352 | - | /// Query-parameter value (slug for tags, raw item_type for types). | |
| 353 | - | pub value: String, | |
| 354 | - | pub count: u32, | |
| 355 | - | pub active: bool, | |
| 356 | - | /// Tag UUID (empty for "All" and type filters). | |
| 357 | - | pub id: String, | |
| 358 | - | /// Whether the viewer follows this tag. | |
| 359 | - | pub following: bool, | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | /// A tag node for the tree browser page. | |
| 363 | - | #[derive(Clone)] | |
| 364 | - | pub struct TagTreeNode { | |
| 365 | - | pub name: String, | |
| 366 | - | pub slug: String, | |
| 367 | - | pub item_count: u32, | |
| 368 | - | pub child_count: usize, | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | /// A breadcrumb in the tag tree hierarchy. | |
| 372 | - | #[derive(Clone)] | |
| 373 | - | pub struct TagBreadcrumb { | |
| 374 | - | pub name: String, | |
| 375 | - | pub slug: String, | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | /// Price filter option | |
| 379 | - | #[derive(Clone)] | |
| 380 | - | pub struct PriceFilter { | |
| 381 | - | pub label: String, | |
| 382 | - | pub count: u32, | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | /// Transaction for payments tab | |
| 386 | - | #[derive(Clone)] | |
| 387 | - | pub struct Transaction { | |
| 388 | - | pub date: String, | |
| 389 | - | pub tx_type: String, | |
| 390 | - | pub description: String, | |
| 391 | - | pub amount: String, | |
| 392 | - | pub is_incoming: bool, | |
| 393 | - | pub status: String, | |
| 394 | - | pub details: String, | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | /// Tip received for payments tab | |
| 398 | - | #[derive(Clone)] | |
| 399 | - | pub struct TipReceived { | |
| 400 | - | pub date: String, | |
| 401 | - | pub tipper_name: String, | |
| 402 | - | pub amount: String, | |
| 403 | - | pub message: Option<String>, | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | /// Project member row for dashboard members tab | |
| 407 | - | #[derive(Clone)] | |
| 408 | - | pub struct ProjectMemberRow { | |
| 409 | - | pub id: String, | |
| 410 | - | pub user_id: String, | |
| 411 | - | pub username: String, | |
| 412 | - | pub display_name: Option<String>, | |
| 413 | - | pub role: String, | |
| 414 | - | pub split_percent: i16, | |
| 415 | - | pub stripe_connected: bool, | |
| 416 | - | pub added_at: String, | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | /// Project card for dashboard | |
| 420 | - | #[derive(Clone)] | |
| 421 | - | pub struct ProjectCard { | |
| 422 | - | pub id: crate::db::ProjectId, | |
| 423 | - | pub title: String, | |
| 424 | - | pub project_type: String, | |
| 425 | - | pub created_date: String, | |
| 426 | - | pub updated_date: Option<String>, | |
| 427 | - | pub stats: String, | |
| 428 | - | pub status: String, | |
| 429 | - | pub slug: String, | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | /// Stat card for dashboards | |
| 433 | - | #[derive(Clone)] | |
| 434 | - | pub struct StatCard { | |
| 435 | - | pub label: String, | |
| 436 | - | pub value: String, | |
| 437 | - | pub change: Option<String>, | |
| 438 | - | pub is_positive: bool, | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | /// A buyer who opted to share their email with the creator. | |
| 442 | - | #[derive(Clone)] | |
| 443 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 444 | - | pub struct BuyerContact { | |
| 445 | - | pub username: String, | |
| 446 | - | pub email: String, | |
| 447 | - | pub total_purchases: i64, | |
| 448 | - | pub total_spent: String, | |
| 449 | - | pub last_purchase: String, | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | /// Per-project comparison row for cross-project analytics. | |
| 453 | - | #[derive(Clone)] | |
| 454 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 455 | - | pub struct ProjectComparison { | |
| 456 | - | pub title: String, | |
| 457 | - | pub revenue: String, | |
| 458 | - | pub revenue_pct: f64, | |
| 459 | - | pub sales: i64, | |
| 460 | - | pub views: i64, | |
| 461 | - | pub conversion: String, | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | /// Content item for project dashboard | |
| 465 | - | #[derive(Clone)] | |
| 466 | - | pub struct ContentItem { | |
| 467 | - | pub position: u32, | |
| 468 | - | pub title: String, | |
| 469 | - | pub item_type: String, | |
| 470 | - | pub price: String, | |
| 471 | - | pub sales: u32, | |
| 472 | - | pub revenue: String, | |
| 473 | - | pub status: String, | |
| 474 | - | pub id: String, | |
| 475 | - | /// True if this item is unlisted (only accessible via bundle). | |
| 476 | - | pub is_unlisted: bool, | |
| 477 | - | /// Child items if this is a bundle (shown nested in the dashboard). | |
| 478 | - | pub children: Vec<ContentItem>, | |
| 479 | - | } | |
| 480 | - | ||
| 481 | - | /// A single step in the creator onboarding checklist. | |
| 482 | - | #[derive(Clone)] | |
| 483 | - | pub struct OnboardingStep { | |
| 484 | - | pub label: &'static str, | |
| 485 | - | pub done: bool, | |
| 486 | - | pub link_tab: &'static str, | |
| 487 | - | pub link_label: &'static str, | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | /// Progress checklist shown to new creators until all steps are complete. | |
| 491 | - | #[derive(Clone)] | |
| 492 | - | pub struct OnboardingChecklist { | |
| 493 | - | pub steps: Vec<OnboardingStep>, | |
| 494 | - | pub completed: usize, | |
| 495 | - | pub total: usize, | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | /// A sync app row for the SyncKit dashboard tab. | |
| 499 | - | #[derive(Clone)] | |
| 500 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 501 | - | pub struct SyncAppRow { | |
| 502 | - | pub id: String, | |
| 503 | - | pub name: String, | |
| 504 | - | pub api_key_masked: String, | |
| 505 | - | pub api_key_full: String, | |
| 506 | - | pub is_active: bool, |
Lines truncated
| @@ -1,4 +1,14 @@ | |||
| 1 | 1 | //! Item CRUD: creation, listing, text body updates, and ownership lookups. | |
| 2 | + | //! | |
| 3 | + | //! Bulk and structural operations (move, bulk_*, duplicate) live in the | |
| 4 | + | //! `bulk` submodule and are re-exported flat so call sites still see | |
| 5 | + | //! `db::items::bulk_publish` etc. | |
| 6 | + | ||
| 7 | + | mod bulk; | |
| 8 | + | mod media; | |
| 9 | + | ||
| 10 | + | pub use bulk::*; | |
| 11 | + | pub use media::*; | |
| 2 | 12 | ||
| 3 | 13 | use sqlx::PgPool; | |
| 4 | 14 | ||
| @@ -78,7 +88,7 @@ | |||
| 78 | 88 | } | |
| 79 | 89 | ||
| 80 | 90 | /// Check whether a slug already exists for a given project. | |
| 81 | - | async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>( | |
| 91 | + | pub(super) async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>( | |
| 82 | 92 | executor: E, | |
| 83 | 93 | project_id: ProjectId, | |
| 84 | 94 | slug: &super::validated_types::Slug, | |
| @@ -884,503 +894,6 @@ | |||
| 884 | 894 | Ok(()) | |
| 885 | 895 | } | |
| 886 | 896 | ||
| 887 | - | /// Reorder an item within its project by swapping with the adjacent item. | |
| 888 | - | /// | |
| 889 | - | /// Normalizes all sort_orders first (0, 1, 2, ...) to handle the cold-start | |
| 890 | - | /// case where all items have sort_order=0, then swaps with the neighbor. | |
| 891 | - | /// Uses `FOR UPDATE` to serialize concurrent reorder requests on the same project. | |
| 892 | - | #[tracing::instrument(skip_all)] | |
| 893 | - | pub async fn move_item( | |
| 894 | - | pool: &PgPool, | |
| 895 | - | project_id: ProjectId, | |
| 896 | - | user_id: UserId, | |
| 897 | - | item_id: ItemId, | |
| 898 | - | direction: &str, | |
| 899 | - | ) -> Result<()> { | |
| 900 | - | let mut tx = pool.begin().await?; | |
| 901 | - | ||
| 902 | - | // Lock and fetch item IDs in display order (scoped to projects owned by user) | |
| 903 | - | let item_ids: Vec<ItemId> = sqlx::query_scalar( | |
| 904 | - | r#" | |
| 905 | - | SELECT id FROM items | |
| 906 | - | WHERE project_id = $1 | |
| 907 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 908 | - | ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE | |
| 909 | - | "#, | |
| 910 | - | ) | |
| 911 | - | .bind(project_id) | |
| 912 | - | .bind(user_id) | |
| 913 | - | .fetch_all(&mut *tx) | |
| 914 | - | .await?; | |
| 915 | - | ||
| 916 | - | let Some(pos) = item_ids.iter().position(|id| *id == item_id) else { | |
| 917 | - | return Ok(()); | |
| 918 | - | }; | |
| 919 | - | ||
| 920 | - | let swap_pos = match direction { | |
| 921 | - | "up" if pos > 0 => pos - 1, | |
| 922 | - | "down" if pos + 1 < item_ids.len() => pos + 1, | |
| 923 | - | _ => return Ok(()), | |
| 924 | - | }; | |
| 925 | - | ||
| 926 | - | // Normalize all sort_orders, swapping the target pair (single batch UPDATE) | |
| 927 | - | let mut ids = Vec::with_capacity(item_ids.len()); | |
| 928 | - | let mut orders = Vec::with_capacity(item_ids.len()); | |
| 929 | - | for (i, id) in item_ids.iter().enumerate() { | |
| 930 | - | ids.push(*id); | |
| 931 | - | orders.push(if i == pos { | |
| 932 | - | swap_pos as i32 | |
| 933 | - | } else if i == swap_pos { | |
| 934 | - | pos as i32 | |
| 935 | - | } else { | |
| 936 | - | i as i32 | |
| 937 | - | }); | |
| 938 | - | } | |
| 939 | - | sqlx::query( | |
| 940 | - | "UPDATE items SET sort_order = batch.ord FROM UNNEST($1::UUID[], $2::INT[]) AS batch(id, ord) WHERE items.id = batch.id", | |
| 941 | - | ) | |
| 942 | - | .bind(&ids) | |
| 943 | - | .bind(&orders) | |
| 944 | - | .execute(&mut *tx) | |
| 945 | - | .await?; | |
| 946 | - | ||
| 947 | - | tx.commit().await?; | |
| 948 | - | Ok(()) | |
| 949 | - | } | |
| 950 | - | ||
| 951 | - | /// Bulk-publish items: set `is_public = true` and clear any scheduled `publish_at`. | |
| 952 | - | /// | |
| 953 | - | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 954 | - | #[tracing::instrument(skip_all)] | |
| 955 | - | pub async fn bulk_publish( | |
| 956 | - | pool: &PgPool, | |
| 957 | - | item_ids: &[ItemId], | |
| 958 | - | project_id: ProjectId, | |
| 959 | - | user_id: UserId, | |
| 960 | - | ) -> Result<u64> { | |
| 961 | - | let result = sqlx::query( | |
| 962 | - | r#" | |
| 963 | - | UPDATE items | |
| 964 | - | SET is_public = true, publish_at = NULL, updated_at = NOW() | |
| 965 | - | WHERE id = ANY($1) AND project_id = $2 | |
| 966 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 967 | - | AND removed_by_admin = false | |
| 968 | - | "#, | |
| 969 | - | ) | |
| 970 | - | .bind(item_ids) | |
| 971 | - | .bind(project_id) | |
| 972 | - | .bind(user_id) | |
| 973 | - | .execute(pool) | |
| 974 | - | .await?; | |
| 975 | - | ||
| 976 | - | Ok(result.rows_affected()) | |
| 977 | - | } | |
| 978 | - | ||
| 979 | - | /// Bulk-unpublish items: set `is_public = false`. | |
| 980 | - | /// | |
| 981 | - | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 982 | - | #[tracing::instrument(skip_all)] | |
| 983 | - | pub async fn bulk_unpublish( | |
| 984 | - | pool: &PgPool, | |
| 985 | - | item_ids: &[ItemId], | |
| 986 | - | project_id: ProjectId, | |
| 987 | - | user_id: UserId, | |
| 988 | - | ) -> Result<u64> { | |
| 989 | - | let result = sqlx::query( | |
| 990 | - | r#" | |
| 991 | - | UPDATE items | |
| 992 | - | SET is_public = false, updated_at = NOW() | |
| 993 | - | WHERE id = ANY($1) AND project_id = $2 | |
| 994 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 995 | - | "#, | |
| 996 | - | ) | |
| 997 | - | .bind(item_ids) | |
| 998 | - | .bind(project_id) | |
| 999 | - | .bind(user_id) | |
| 1000 | - | .execute(pool) | |
| 1001 | - | .await?; | |
| 1002 | - | ||
| 1003 | - | Ok(result.rows_affected()) | |
| 1004 | - | } | |
| 1005 | - | ||
| 1006 | - | /// Soft-delete items from a project (sets deleted_at, recoverable for 7 days). | |
| 1007 | - | /// | |
| 1008 | - | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 1009 | - | #[tracing::instrument(skip_all)] | |
| 1010 | - | pub async fn bulk_delete( | |
| 1011 | - | pool: &PgPool, | |
| 1012 | - | item_ids: &[ItemId], | |
| 1013 | - | project_id: ProjectId, | |
| 1014 | - | user_id: UserId, | |
| 1015 | - | ) -> Result<u64> { | |
| 1016 | - | let result = sqlx::query( | |
| 1017 | - | r#" | |
| 1018 | - | UPDATE items SET deleted_at = NOW(), is_public = false | |
| 1019 | - | WHERE id = ANY($1) AND project_id = $2 AND deleted_at IS NULL | |
| 1020 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 1021 | - | "#, | |
| 1022 | - | ) | |
| 1023 | - | .bind(item_ids) | |
| 1024 | - | .bind(project_id) | |
| 1025 | - | .bind(user_id) | |
| 1026 | - | .execute(pool) | |
| 1027 | - | .await?; | |
| 1028 | - | ||
| 1029 | - | Ok(result.rows_affected()) | |
| 1030 | - | } | |
| 1031 | - | ||
| 1032 | - | /// Bulk-update price on selected items. | |
| 1033 | - | /// | |
| 1034 | - | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 1035 | - | #[tracing::instrument(skip_all)] | |
| 1036 | - | pub async fn bulk_update_price( | |
| 1037 | - | pool: &PgPool, | |
| 1038 | - | item_ids: &[ItemId], | |
| 1039 | - | project_id: ProjectId, | |
| 1040 | - | user_id: UserId, | |
| 1041 | - | price_cents: PriceCents, | |
| 1042 | - | ) -> Result<u64> { | |
| 1043 | - | let result = sqlx::query( | |
| 1044 | - | r#" | |
| 1045 | - | UPDATE items SET price_cents = $4 | |
| 1046 | - | WHERE id = ANY($1) AND project_id = $2 | |
| 1047 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 1048 | - | "#, | |
| 1049 | - | ) | |
| 1050 | - | .bind(item_ids) | |
| 1051 | - | .bind(project_id) | |
| 1052 | - | .bind(user_id) | |
| 1053 | - | .bind(price_cents) | |
| 1054 | - | .execute(pool) | |
| 1055 | - | .await?; | |
| 1056 | - | ||
| 1057 | - | Ok(result.rows_affected()) | |
| 1058 | - | } | |
| 1059 | - | ||
| 1060 | - | /// Bulk-add a tag to selected items (skips duplicates via ON CONFLICT). | |
| 1061 | - | /// | |
| 1062 | - | /// Returns number of new tag associations created. | |
| 1063 | - | #[tracing::instrument(skip_all)] | |
| 1064 | - | pub async fn bulk_add_tag( | |
| 1065 | - | pool: &PgPool, | |
| 1066 | - | item_ids: &[ItemId], | |
| 1067 | - | project_id: ProjectId, | |
| 1068 | - | user_id: UserId, | |
| 1069 | - | tag_id: super::TagId, | |
| 1070 | - | ) -> Result<u64> { | |
| 1071 | - | // Verify all items belong to the project owned by this user, | |
| 1072 | - | // then insert tag associations for each. | |
| 1073 | - | let result = sqlx::query( | |
| 1074 | - | r#" | |
| 1075 | - | INSERT INTO item_tags (item_id, tag_id) | |
| 1076 | - | SELECT i.id, $4 | |
| 1077 | - | FROM items i | |
| 1078 | - | JOIN projects p ON i.project_id = p.id | |
| 1079 | - | WHERE i.id = ANY($1) AND i.project_id = $2 AND p.user_id = $3 | |
| 1080 | - | ON CONFLICT (item_id, tag_id) DO NOTHING | |
| 1081 | - | "#, | |
| 1082 | - | ) | |
| 1083 | - | .bind(item_ids) | |
| 1084 | - | .bind(project_id) | |
| 1085 | - | .bind(user_id) | |
| 1086 | - | .bind(tag_id) | |
| 1087 | - | .execute(pool) | |
| 1088 | - | .await?; | |
| 1089 | - | ||
| 1090 | - | Ok(result.rows_affected()) | |
| 1091 | - | } | |
| 1092 | - | ||
| 1093 | - | /// Duplicate an item and its metadata (tags, chapters, content insertion placements). | |
| 1094 | - | /// | |
| 1095 | - | /// Creates a draft copy with "Copy of …" title. Does not copy versions (S3 files), | |
| 1096 | - | /// license keys, download codes, or discount codes. | |
| 1097 | - | #[tracing::instrument(skip_all)] | |
| 1098 | - | pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) -> Result<DbItem> { | |
| 1099 | - | let mut tx = pool.begin().await?; | |
| 1100 | - | ||
| 1101 | - | // Generate a unique slug for the copy (verify ownership via project) | |
| 1102 | - | let source = sqlx::query_as::<_, DbItem>( | |
| 1103 | - | "SELECT * FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)", | |
| 1104 | - | ) | |
| 1105 | - | .bind(source_id) | |
| 1106 | - | .bind(user_id) | |
| 1107 | - | .fetch_one(&mut *tx) | |
| 1108 | - | .await?; | |
| 1109 | - | let copy_title = format!("Copy of {}", &source.title); | |
| 1110 | - | let copy_title: String = copy_title.chars().take(200).collect(); | |
| 1111 | - | let mut slug = crate::helpers::slugify(©_title); | |
| 1112 | - | if item_slug_exists(&mut *tx, source.project_id, &slug).await? { | |
| 1113 | - | let base = slug.clone(); | |
| 1114 | - | let mut counter = 2u32; | |
| 1115 | - | loop { | |
| 1116 | - | if counter > 100 { | |
| 1117 | - | return Err(crate::error::AppError::BadRequest( | |
| 1118 | - | "Too many copies with similar names. Rename an existing copy first.".to_string(), | |
| 1119 | - | )); | |
| 1120 | - | } | |
| 1121 | - | slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter)); | |
| 1122 | - | if !item_slug_exists(&mut *tx, source.project_id, &slug).await? { | |
| 1123 | - | break; | |
| 1124 | - | } | |
| 1125 | - | counter += 1; | |
| 1126 | - | } | |
| 1127 | - | } | |
| 1128 | - | ||
| 1129 | - | // Step 1: Clone item row | |
| 1130 | - | let new_item = sqlx::query_as::<_, DbItem>( | |
| 1131 | - | r#" | |
| 1132 | - | INSERT INTO items ( | |
| 1133 | - | project_id, title, description, price_cents, item_type, thumbnail_url, | |
| 1134 | - | sort_order, body, word_count, reading_time_minutes, duration_seconds, | |
| 1135 | - | episode_number, enable_license_keys, default_max_activations, | |
| 1136 | - | pwyw_enabled, pwyw_min_cents, is_public, slug | |
| 1137 | - | ) | |
| 1138 | - | SELECT | |
| 1139 | - | project_id, LEFT('Copy of ' || title, 200), description, price_cents, | |
| 1140 | - | item_type, thumbnail_url, sort_order, body, word_count, | |
| 1141 | - | reading_time_minutes, duration_seconds, episode_number, | |
| 1142 | - | enable_license_keys, default_max_activations, pwyw_enabled, | |
| 1143 | - | pwyw_min_cents, false, $2 | |
| 1144 | - | FROM items WHERE id = $1 | |
| 1145 | - | RETURNING * | |
| 1146 | - | "#, | |
| 1147 | - | ) | |
| 1148 | - | .bind(source_id) | |
| 1149 | - | .bind(&slug) | |
| 1150 | - | .fetch_one(&mut *tx) | |
| 1151 | - | .await?; | |
| 1152 | - | ||
| 1153 | - | // Step 2: Copy tags | |
| 1154 | - | sqlx::query( | |
| 1155 | - | r#" | |
| 1156 | - | INSERT INTO item_tags (item_id, tag_id, is_primary) | |
| 1157 | - | SELECT $2, tag_id, is_primary FROM item_tags WHERE item_id = $1 | |
| 1158 | - | "#, | |
| 1159 | - | ) | |
| 1160 | - | .bind(source_id) | |
| 1161 | - | .bind(new_item.id) | |
| 1162 | - | .execute(&mut *tx) | |
| 1163 | - | .await?; | |
| 1164 | - | ||
| 1165 | - | // Step 3: Copy chapters | |
| 1166 | - | sqlx::query( | |
| 1167 | - | r#" | |
| 1168 | - | INSERT INTO chapters (item_id, title, start_seconds, sort_order) | |
| 1169 | - | SELECT $2, title, start_seconds, sort_order FROM chapters WHERE item_id = $1 | |
| 1170 | - | "#, | |
| 1171 | - | ) | |
| 1172 | - | .bind(source_id) | |
| 1173 | - | .bind(new_item.id) | |
| 1174 | - | .execute(&mut *tx) | |
| 1175 | - | .await?; | |
| 1176 | - | ||
| 1177 | - | // Step 4: Copy content insertion placements | |
| 1178 | - | sqlx::query( | |
| 1179 | - | r#" | |
| 1180 | - | INSERT INTO content_insertion_placements (item_id, insertion_id, position, offset_ms, sort_order) | |
| 1181 | - | SELECT $2, insertion_id, position, offset_ms, sort_order | |
| 1182 | - | FROM content_insertion_placements WHERE item_id = $1 | |
| 1183 | - | "#, | |
| 1184 | - | ) | |
| 1185 | - | .bind(source_id) | |
| 1186 | - | .bind(new_item.id) | |
| 1187 | - | .execute(&mut *tx) | |
| 1188 | - | .await?; | |
| 1189 | - | ||
| 1190 | - | tx.commit().await?; | |
| 1191 | - | ||
| 1192 | - | Ok(new_item) | |
| 1193 | - | } | |
| 1194 | - | ||
| 1195 | - | /// Get the audio, cover, and video file sizes for an item (for storage decrement on delete). | |
| 1196 | - | #[tracing::instrument(skip_all)] | |
| 1197 | - | pub async fn get_item_file_sizes( | |
| 1198 | - | pool: &PgPool, | |
| 1199 | - | id: ItemId, | |
| 1200 | - | ) -> Result<super::models::ItemFileSizes> { | |
| 1201 | - | let row = sqlx::query_as::<_, (Option<i64>, Option<i64>, Option<i64>)>( | |
| 1202 | - | "SELECT audio_file_size_bytes, cover_file_size_bytes, video_file_size_bytes FROM items WHERE id = $1", | |
| 1203 | - | ) | |
| 1204 | - | .bind(id) | |
| 1205 | - | .fetch_optional(pool) | |
| 1206 | - | .await?; | |
| 1207 | - | ||
| 1208 | - | match row { | |
| 1209 | - | Some((audio, cover, video)) => Ok(super::models::ItemFileSizes { | |
| 1210 | - | audio_file_size_bytes: audio, | |
| 1211 | - | cover_file_size_bytes: cover, | |
| 1212 | - | video_file_size_bytes: video, | |
| 1213 | - | }), | |
| 1214 | - | None => Ok(super::models::ItemFileSizes { | |
| 1215 | - | audio_file_size_bytes: None, | |
| 1216 | - | cover_file_size_bytes: None, | |
| 1217 | - | video_file_size_bytes: None, | |
| 1218 | - | }), | |
| 1219 | - | } | |
| 1220 | - | } | |
| 1221 | - | ||
| 1222 | - | /// Update the audio file size on an item (defense-in-depth: verifies ownership). | |
| 1223 | - | #[tracing::instrument(skip_all)] | |
| 1224 | - | pub async fn update_item_audio_file_size( | |
| 1225 | - | pool: &PgPool, | |
| 1226 | - | item_id: ItemId, | |
| 1227 | - | user_id: UserId, | |
| 1228 | - | bytes: i64, | |
| 1229 | - | ) -> Result<()> { | |
| 1230 | - | sqlx::query( | |
| 1231 | - | "UPDATE items SET audio_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 1232 | - | ) | |
| 1233 | - | .bind(item_id) | |
| 1234 | - | .bind(bytes) | |
| 1235 | - | .bind(user_id) | |
| 1236 | - | .execute(pool) | |
| 1237 | - | .await?; | |
| 1238 | - | ||
| 1239 | - | Ok(()) | |
| 1240 | - | } | |
| 1241 | - | ||
| 1242 | - | /// Update the cover image URL for an item (defense-in-depth: verifies ownership). | |
| 1243 | - | #[tracing::instrument(skip_all)] | |
| 1244 | - | pub async fn update_item_cover_image_url( | |
| 1245 | - | pool: &PgPool, | |
| 1246 | - | item_id: ItemId, | |
| 1247 | - | user_id: UserId, | |
| 1248 | - | url: &str, | |
| 1249 | - | ) -> Result<()> { | |
| 1250 | - | sqlx::query( | |
| 1251 | - | "UPDATE items SET cover_image_url = $2, updated_at = NOW() WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 1252 | - | ) | |
| 1253 | - | .bind(item_id) | |
| 1254 | - | .bind(url) | |
| 1255 | - | .bind(user_id) | |
| 1256 | - | .execute(pool) | |
| 1257 | - | .await?; | |
| 1258 | - | ||
| 1259 | - | Ok(()) | |
| 1260 | - | } | |
| 1261 | - | ||
| 1262 | - | /// Atomically update cover image URL, S3 key, and file size in a single UPDATE | |
| 1263 | - | /// (defense-in-depth: verifies ownership). | |
| 1264 | - | #[tracing::instrument(skip_all)] | |
| 1265 | - | pub async fn update_item_cover( | |
| 1266 | - | pool: &PgPool, | |
| 1267 | - | item_id: ItemId, | |
| 1268 | - | user_id: UserId, | |
| 1269 | - | url: &str, | |
| 1270 | - | s3_key: &str, | |
| 1271 | - | file_size_bytes: i64, | |
| 1272 | - | ) -> Result<()> { | |
| 1273 | - | sqlx::query( | |
| 1274 | - | r#"UPDATE items | |
| 1275 | - | SET cover_image_url = $2, cover_s3_key = $3, cover_file_size_bytes = $4, updated_at = NOW() | |
| 1276 | - | WHERE id = $1 | |
| 1277 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $5)"#, | |
| 1278 | - | ) | |
| 1279 | - | .bind(item_id) | |
| 1280 | - | .bind(url) | |
| 1281 | - | .bind(s3_key) | |
| 1282 | - | .bind(file_size_bytes) | |
| 1283 | - | .bind(user_id) | |
| 1284 | - | .execute(pool) | |
| 1285 | - | .await?; | |
| 1286 | - | ||
| 1287 | - | Ok(()) | |
| 1288 | - | } | |
| 1289 | - | ||
| 1290 | - | /// Update the cover file size on an item (defense-in-depth: verifies ownership). | |
| 1291 | - | #[tracing::instrument(skip_all)] | |
| 1292 | - | pub async fn update_item_cover_file_size( | |
| 1293 | - | pool: &PgPool, | |
| 1294 | - | item_id: ItemId, | |
| 1295 | - | user_id: UserId, | |
| 1296 | - | bytes: i64, | |
| 1297 | - | ) -> Result<()> { | |
| 1298 | - | sqlx::query( | |
| 1299 | - | "UPDATE items SET cover_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 1300 | - | ) | |
| 1301 | - | .bind(item_id) | |
| 1302 | - | .bind(bytes) | |
| 1303 | - | .bind(user_id) | |
| 1304 | - | .execute(pool) | |
| 1305 | - | .await?; | |
| 1306 | - | ||
| 1307 | - | Ok(()) | |
| 1308 | - | } | |
| 1309 | - | ||
| 1310 | - | /// Update the video S3 key for an item (defense-in-depth: verifies ownership). | |
| 1311 | - | #[tracing::instrument(skip_all)] | |
| 1312 | - | pub async fn update_item_video_s3_key( | |
| 1313 | - | pool: &PgPool, | |
| 1314 | - | item_id: ItemId, | |
| 1315 | - | user_id: UserId, | |
| 1316 | - | s3_key: &str, | |
| 1317 | - | ) -> Result<DbItem> { | |
| 1318 | - | let item = sqlx::query_as::<_, DbItem>( | |
| 1319 | - | r#" | |
| 1320 | - | UPDATE items | |
| 1321 | - | SET video_s3_key = $2, updated_at = NOW() | |
| 1322 | - | WHERE id = $1 | |
| 1323 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 1324 | - | RETURNING * | |
| 1325 | - | "#, | |
| 1326 | - | ) | |
| 1327 | - | .bind(item_id) | |
| 1328 | - | .bind(s3_key) | |
| 1329 | - | .bind(user_id) | |
| 1330 | - | .fetch_one(pool) | |
| 1331 | - | .await?; | |
| 1332 | - | ||
| 1333 | - | Ok(item) | |
| 1334 | - | } | |
| 1335 | - | ||
| 1336 | - | /// Update the video file size on an item (defense-in-depth: verifies ownership). | |
| 1337 | - | #[tracing::instrument(skip_all)] | |
| 1338 | - | pub async fn update_item_video_file_size( | |
| 1339 | - | pool: &PgPool, | |
| 1340 | - | item_id: ItemId, | |
| 1341 | - | user_id: UserId, | |
| 1342 | - | bytes: i64, | |
| 1343 | - | ) -> Result<()> { | |
| 1344 | - | sqlx::query( | |
| 1345 | - | "UPDATE items SET video_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 1346 | - | ) | |
| 1347 | - | .bind(item_id) | |
| 1348 | - | .bind(bytes) | |
| 1349 | - | .bind(user_id) | |
| 1350 | - | .execute(pool) | |
| 1351 | - | .await?; | |
| 1352 | - | ||
| 1353 | - | Ok(()) | |
| 1354 | - | } | |
| 1355 | - | ||
| 1356 | - | /// Update video metadata (duration, resolution) on an item (defense-in-depth: verifies ownership). | |
| 1357 | - | #[tracing::instrument(skip_all)] | |
| 1358 | - | pub async fn update_item_video_metadata( | |
| 1359 | - | pool: &PgPool, | |
| 1360 | - | item_id: ItemId, | |
| 1361 | - | user_id: UserId, |
Lines truncated
| @@ -1,11 +1,19 @@ | |||
| 1 | 1 | //! Templates for public-facing pages: landing, auth, content, blog, discover. | |
| 2 | + | //! | |
| 3 | + | //! Git source-browser templates live in the `git` submodule and are | |
| 4 | + | //! re-exported flat — call sites still see `templates::GitRepoTemplate` etc. | |
| 5 | + | ||
| 6 | + | mod git; | |
| 7 | + | mod health; | |
| 8 | + | ||
| 9 | + | pub use git::*; | |
| 10 | + | pub use health::*; | |
| 2 | 11 | ||
| 3 | 12 | use std::sync::Arc; | |
| 4 | 13 | ||
| 5 | 14 | use askama::Template; | |
| 6 | 15 | ||
| 7 | 16 | use crate::auth::SessionUser; | |
| 8 | - | use crate::git; | |
| 9 | 17 | use crate::types::*; | |
| 10 | 18 | ||
| 11 | 19 | use super::CsrfTokenOption; | |
| @@ -705,384 +713,4 @@ | |||
| 705 | 713 | pub csrf_token: CsrfTokenOption, | |
| 706 | 714 | } | |
| 707 | 715 | ||
| 708 | - | // ============================================================================ | |
| 709 | - | // Health Page | |
| 710 | - | // ============================================================================ | |
| 711 | 716 | ||
| 712 | - | /// Test result for health page display. | |
| 713 | - | #[derive(Clone)] | |
| 714 | - | pub struct HealthTest { | |
| 715 | - | pub name: String, | |
| 716 | - | pub passed: bool, | |
| 717 | - | pub latency_ms: u64, | |
| 718 | - | } | |
| 719 | - | ||
| 720 | - | /// Pre-formatted health snapshot for template rendering. | |
| 721 | - | #[derive(Clone)] | |
| 722 | - | pub struct PrivacyJobDisplay { | |
| 723 | - | pub name: String, | |
| 724 | - | pub description: String, | |
| 725 | - | pub last_ran: String, | |
| 726 | - | pub rows_affected: String, | |
| 727 | - | pub status_class: String, | |
| 728 | - | } | |
| 729 | - | ||
| 730 | - | /// Pre-formatted health check snapshot for template rendering. | |
| 731 | - | pub struct HealthSnapshotDisplay { | |
| 732 | - | pub checked_at: String, | |
| 733 | - | pub status: String, | |
| 734 | - | pub status_class: String, | |
| 735 | - | pub duration_ms: i32, | |
| 736 | - | } | |
| 737 | - | ||
| 738 | - | /// Pre-formatted PoM snapshot for template rendering. | |
| 739 | - | #[derive(Clone)] | |
| 740 | - | pub struct PomSnapshotDisplay { | |
| 741 | - | pub checked_at: String, | |
| 742 | - | pub status: String, | |
| 743 | - | pub status_class: String, | |
| 744 | - | pub response_time_ms: i64, | |
| 745 | - | } | |
| 746 | - | ||
| 747 | - | /// Pre-formatted PoM incident for template rendering. | |
| 748 | - | #[derive(Clone)] | |
| 749 | - | pub struct PomIncidentDisplay { | |
| 750 | - | pub to_status: String, | |
| 751 | - | pub started_at: String, | |
| 752 | - | pub duration: String, | |
| 753 | - | } | |
| 754 | - | ||
| 755 | - | /// Public page: platform health status and monitoring dashboard. | |
| 756 | - | #[derive(Template)] | |
| 757 | - | #[template(path = "pages/health.html")] | |
| 758 | - | pub struct HealthTemplate { | |
| 759 | - | pub csrf_token: CsrfTokenOption, | |
| 760 | - | pub session_user: Option<SessionUser>, | |
| 761 | - | // Overall status | |
| 762 | - | pub overall_status: String, | |
| 763 | - | pub overall_status_class: String, | |
| 764 | - | pub uptime: String, | |
| 765 | - | pub version: String, | |
| 766 | - | pub check_duration_ms: u64, | |
| 767 | - | // Database | |
| 768 | - | pub db_status: String, | |
| 769 | - | pub db_status_class: String, | |
| 770 | - | pub db_pool_size: String, | |
| 771 | - | pub db_pool_max: String, | |
| 772 | - | pub db_pool_utilization: String, | |
| 773 | - | pub db_active_connections: String, | |
| 774 | - | pub user_count: String, | |
| 775 | - | pub project_count: String, | |
| 776 | - | pub item_count: String, | |
| 777 | - | pub transaction_count: String, | |
| 778 | - | pub blog_post_count: String, | |
| 779 | - | // Sessions | |
| 780 | - | pub session_status: String, | |
| 781 | - | pub session_status_class: String, | |
| 782 | - | pub active_sessions: String, | |
| 783 | - | // Storage | |
| 784 | - | pub storage_status: String, | |
| 785 | - | pub storage_status_class: String, | |
| 786 | - | pub storage_configured: bool, | |
| 787 | - | pub storage_bucket: String, | |
| 788 | - | pub storage_region: String, | |
| 789 | - | // Stripe | |
| 790 | - | pub stripe_status: String, | |
| 791 | - | pub stripe_status_class: String, | |
| 792 | - | pub stripe_configured: bool, | |
| 793 | - | pub stripe_mode: String, | |
| 794 | - | pub connected_creators: String, | |
| 795 | - | ||
| 796 | - | pub email_status: String, | |
| 797 | - | pub email_status_class: String, | |
| 798 | - | pub email_provider: String, | |
| 799 | - | // SyncKit | |
| 800 | - | pub synckit_status: String, | |
| 801 | - | pub synckit_status_class: String, | |
| 802 | - | pub synckit_configured: bool, | |
| 803 | - | pub synckit_app_count: String, | |
| 804 | - | pub synckit_device_count: String, | |
| 805 | - | pub synckit_log_entries: String, | |
| 806 | - | // Security & Monitoring | |
| 807 | - | pub admin_status: String, | |
| 808 | - | // Background monitor | |
| 809 | - | pub monitor_enabled: bool, | |
| 810 | - | pub monitor_interval_secs: u64, | |
| 811 | - | pub alerts_configured: bool, | |
| 812 | - | pub uptime_24h: Option<String>, | |
| 813 | - | pub uptime_7d: Option<String>, | |
| 814 | - | pub last_incident: Option<String>, | |
| 815 | - | pub recent_snapshots: Vec<HealthSnapshotDisplay>, | |
| 816 | - | // Server | |
| 817 | - | pub environment: String, | |
| 818 | - | pub host: Arc<str>, | |
| 819 | - | pub started_at: String, | |
| 820 | - | // Privacy & Compliance | |
| 821 | - | pub privacy_jobs: Vec<PrivacyJobDisplay>, | |
| 822 | - | // Tests | |
| 823 | - | pub public_tests: Vec<HealthTest>, | |
| 824 | - | pub db_tests: Vec<HealthTest>, | |
| 825 | - | pub generated_at: String, | |
| 826 | - | // External monitoring (PoM) | |
| 827 | - | pub pom_available: bool, | |
| 828 | - | pub pom_status: Option<String>, | |
| 829 | - | pub pom_status_class: Option<String>, | |
| 830 | - | pub pom_response_time_ms: Option<i64>, | |
| 831 | - | pub pom_checked_at: Option<String>, | |
| 832 | - | pub pom_uptime_24h: Option<String>, | |
| 833 | - | pub pom_uptime_7d: Option<String>, | |
| 834 | - | pub pom_recent: Vec<PomSnapshotDisplay>, | |
| 835 | - | pub pom_avg_latency: Option<String>, | |
| 836 | - | pub pom_p95_latency: Option<String>, | |
| 837 | - | pub pom_incident_active: bool, | |
| 838 | - | pub pom_incident_status: Option<String>, | |
| 839 | - | pub pom_incident_since: Option<String>, | |
| 840 | - | pub pom_recent_incidents: Vec<PomIncidentDisplay>, | |
| 841 | - | // External monitoring (PoM) — route checks | |
| 842 | - | pub pom_routes_total: usize, | |
| 843 | - | pub pom_routes_ok: usize, | |
| 844 | - | pub pom_routes_failed: Vec<String>, | |
| 845 | - | } | |
| 846 | - | ||
| 847 | - | // ============================================================================ | |
| 848 | - | // Git Source Browser | |
| 849 | - | // ============================================================================ | |
| 850 | - | ||
| 851 | - | /// An item paired with its versions, for release display on the git repo page. | |
| 852 | - | pub struct ReleaseItem { | |
| 853 | - | pub item: Item, | |
| 854 | - | pub versions: Vec<Version>, | |
| 855 | - | } | |
| 856 | - | ||
| 857 | - | /// Repository overview: file tree at HEAD + README. | |
| 858 | - | #[derive(Template)] | |
| 859 | - | #[template(path = "pages/git/repo.html")] | |
| 860 | - | pub struct GitRepoTemplate { | |
| 861 | - | pub csrf_token: CsrfTokenOption, | |
| 862 | - | pub session_user: Option<SessionUser>, | |
| 863 | - | pub owner: String, | |
| 864 | - | pub repo_name: String, | |
| 865 | - | pub description: Option<String>, | |
| 866 | - | pub current_ref: String, | |
| 867 | - | pub refs: Vec<git::RefInfo>, | |
| 868 | - | pub tree_items: Vec<git::TreeItem>, | |
| 869 | - | pub readme_html: Option<String>, | |
| 870 | - | pub host_url: Arc<str>, | |
| 871 | - | /// Hostname for SSH clone URLs (e.g., "git.makenot.work"). Hidden when `None`. | |
| 872 | - | pub git_ssh_host: Option<String>, | |
| 873 | - | /// Linked project, if this repo is associated with a public project. | |
| 874 | - | pub linked_project: Option<Project>, | |
| 875 | - | /// Public items with versions from the linked project (releases). | |
| 876 | - | pub release_items: Vec<ReleaseItem>, | |
| 877 | - | /// Number of open issues for the nav bar badge. | |
| 878 | - | pub open_issue_count: i64, | |
| 879 | - | /// Whether the current viewer is the repo owner (for settings link). | |
| 880 | - | pub is_owner: bool, | |
| 881 | - | pub active_tab: &'static str, | |
| 882 | - | } | |
| 883 | - | ||
| 884 | - | /// Subdirectory listing with breadcrumb navigation. | |
| 885 | - | #[derive(Template)] | |
| 886 | - | #[template(path = "pages/git/tree.html")] | |
| 887 | - | pub struct GitTreeTemplate { | |
| 888 | - | pub csrf_token: CsrfTokenOption, | |
| 889 | - | pub session_user: Option<SessionUser>, | |
| 890 | - | pub owner: String, | |
| 891 | - | pub repo_name: String, | |
| 892 | - | pub current_ref: String, | |
| 893 | - | pub refs: Vec<git::RefInfo>, | |
| 894 | - | pub path: String, | |
| 895 | - | pub parent_path: String, | |
| 896 | - | /// Whether we're in a subdirectory (for ".." link and path joining). | |
| 897 | - | pub in_subdir: bool, | |
| 898 | - | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 899 | - | pub tree_items: Vec<git::TreeItem>, | |
| 900 | - | pub open_issue_count: i64, | |
| 901 | - | pub is_owner: bool, | |
| 902 | - | pub active_tab: &'static str, | |
| 903 | - | } | |
| 904 | - | ||
| 905 | - | /// File viewer with syntax highlighting and line numbers. | |
| 906 | - | #[derive(Template)] | |
| 907 | - | #[template(path = "pages/git/file.html")] | |
| 908 | - | pub struct GitFileTemplate { | |
| 909 | - | pub csrf_token: CsrfTokenOption, | |
| 910 | - | pub session_user: Option<SessionUser>, | |
| 911 | - | pub owner: String, | |
| 912 | - | pub repo_name: String, | |
| 913 | - | pub current_ref: String, | |
| 914 | - | pub refs: Vec<git::RefInfo>, | |
| 915 | - | pub file_path: String, | |
| 916 | - | pub filename: String, | |
| 917 | - | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 918 | - | pub file_size: String, | |
| 919 | - | pub line_count: usize, | |
| 920 | - | pub is_binary: bool, | |
| 921 | - | pub highlighted_lines: Vec<String>, | |
| 922 | - | pub open_issue_count: i64, | |
| 923 | - | pub is_owner: bool, | |
| 924 | - | pub active_tab: &'static str, | |
| 925 | - | } | |
| 926 | - | ||
| 927 | - | /// Commit log with pagination. | |
| 928 | - | #[derive(Template)] | |
| 929 | - | #[template(path = "pages/git/commits.html")] | |
| 930 | - | pub struct GitCommitsTemplate { | |
| 931 | - | pub csrf_token: CsrfTokenOption, | |
| 932 | - | pub session_user: Option<SessionUser>, | |
| 933 | - | pub owner: String, | |
| 934 | - | pub repo_name: String, | |
| 935 | - | pub current_ref: String, | |
| 936 | - | pub refs: Vec<git::RefInfo>, | |
| 937 | - | pub commits: Vec<git::CommitInfo>, | |
| 938 | - | pub page: usize, | |
| 939 | - | pub has_more: bool, | |
| 940 | - | pub open_issue_count: i64, | |
| 941 | - | /// Whether the current viewer is the repo owner (for settings link). | |
| 942 | - | pub is_owner: bool, | |
| 943 | - | pub active_tab: &'static str, | |
| 944 | - | } | |
| 945 | - | ||
| 946 | - | /// Commit detail page with inline diffs. | |
| 947 | - | #[derive(Template)] | |
| 948 | - | #[template(path = "pages/git/commit.html")] | |
| 949 | - | pub struct GitCommitDetailTemplate { | |
| 950 | - | pub csrf_token: CsrfTokenOption, | |
| 951 | - | pub session_user: Option<SessionUser>, | |
| 952 | - | pub owner: String, | |
| 953 | - | pub repo_name: String, | |
| 954 | - | pub current_ref: String, | |
| 955 | - | pub refs: Vec<git::RefInfo>, | |
| 956 | - | pub detail: git::CommitDetail, | |
| 957 | - | pub diff_files: Vec<git::DiffFile>, | |
| 958 | - | pub total_files: usize, | |
| 959 | - | pub total_additions: usize, | |
| 960 | - | pub total_deletions: usize, | |
| 961 | - | pub open_issue_count: i64, | |
| 962 | - | pub is_owner: bool, | |
| 963 | - | pub active_tab: &'static str, | |
| 964 | - | } | |
| 965 | - | ||
| 966 | - | /// Blame view for a single file. | |
| 967 | - | #[derive(Template)] | |
| 968 | - | #[template(path = "pages/git/blame.html")] | |
| 969 | - | pub struct GitBlameTemplate { | |
| 970 | - | pub csrf_token: CsrfTokenOption, | |
| 971 | - | pub session_user: Option<SessionUser>, | |
| 972 | - | pub owner: String, | |
| 973 | - | pub repo_name: String, | |
| 974 | - | pub current_ref: String, | |
| 975 | - | pub refs: Vec<git::RefInfo>, | |
| 976 | - | pub file_path: String, | |
| 977 | - | pub filename: String, | |
| 978 | - | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 979 | - | pub blame_lines: Vec<git::BlameLine>, | |
| 980 | - | pub open_issue_count: i64, | |
| 981 | - | pub is_owner: bool, | |
| 982 | - | pub active_tab: &'static str, | |
| 983 | - | } | |
| 984 | - | ||
| 985 | - | /// User's repository listing page. | |
| 986 | - | #[derive(Template)] | |
| 987 | - | #[template(path = "pages/git/repos.html")] | |
| 988 | - | pub struct GitUserReposTemplate { | |
| 989 | - | pub csrf_token: CsrfTokenOption, | |
| 990 | - | pub session_user: Option<SessionUser>, | |
| 991 | - | pub owner: String, | |
| 992 | - | pub repos: Vec<crate::db::DbGitRepo>, | |
| 993 | - | pub is_owner: bool, | |
| 994 | - | } | |
| 995 | - | ||
| 996 | - | /// Public explore page listing all public repos across all users. | |
| 997 | - | #[derive(Template)] | |
| 998 | - | #[template(path = "pages/git/explore.html")] | |
| 999 | - | pub struct GitExploreTemplate { | |
| 1000 | - | pub csrf_token: CsrfTokenOption, | |
| 1001 | - | pub session_user: Option<SessionUser>, | |
| 1002 | - | pub repos: Vec<crate::db::git_repos::PublicRepoWithOwner>, | |
| 1003 | - | pub page: usize, | |
| 1004 | - | pub has_more: bool, | |
| 1005 | - | pub total_count: i64, | |
| 1006 | - | } | |
| 1007 | - | ||
| 1008 | - | /// Per-file commit history with breadcrumb context. | |
| 1009 | - | #[derive(Template)] | |
| 1010 | - | #[template(path = "pages/git/file_log.html")] | |
| 1011 | - | pub struct GitFileLogTemplate { | |
| 1012 | - | pub csrf_token: CsrfTokenOption, | |
| 1013 | - | pub session_user: Option<SessionUser>, | |
| 1014 | - | pub owner: String, | |
| 1015 | - | pub repo_name: String, | |
| 1016 | - | pub current_ref: String, | |
| 1017 | - | pub refs: Vec<git::RefInfo>, | |
| 1018 | - | pub file_path: String, | |
| 1019 | - | pub filename: String, | |
| 1020 | - | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 1021 | - | pub commits: Vec<git::CommitInfo>, | |
| 1022 | - | pub page: usize, | |
| 1023 | - | pub has_more: bool, | |
| 1024 | - | pub open_issue_count: i64, | |
| 1025 | - | pub is_owner: bool, | |
| 1026 | - | pub active_tab: &'static str, | |
| 1027 | - | } | |
| 1028 | - | ||
| 1029 | - | // ============================================================================ | |
| 1030 | - | // Git Issues | |
| 1031 | - | // ============================================================================ | |
| 1032 | - | ||
| 1033 | - | /// Issue list page with status tabs and search (read-only). | |
| 1034 | - | #[derive(Template)] | |
| 1035 | - | #[template(path = "pages/git/issues.html")] | |
| 1036 | - | pub struct GitIssueListTemplate { | |
| 1037 | - | pub csrf_token: CsrfTokenOption, | |
| 1038 | - | pub session_user: Option<SessionUser>, | |
| 1039 | - | pub owner: String, | |
| 1040 | - | pub repo_name: String, | |
| 1041 | - | pub current_ref: String, | |
| 1042 | - | pub issues: Vec<crate::db::DbIssueWithMeta>, | |
| 1043 | - | pub open_count: i64, | |
| 1044 | - | pub closed_count: i64, | |
| 1045 | - | pub current_status: String, | |
| 1046 | - | pub search_query: String, | |
| 1047 | - | pub current_page: i64, | |
| 1048 | - | pub total_pages: i64, | |
| 1049 | - | /// Whether the current viewer is the repo owner (for settings link). | |
| 1050 | - | pub is_owner: bool, | |
| 1051 | - | /// Email address for submitting new issues. | |
| 1052 | - | pub email_address: String, | |
| 1053 | - | } | |
| 1054 | - | ||
| 1055 | - | /// Issue detail page with comments (read-only). | |
| 1056 | - | #[derive(Template)] | |
| 1057 | - | #[template(path = "pages/git/issue.html")] | |
| 1058 | - | pub struct GitIssueDetailTemplate { | |
| 1059 | - | pub csrf_token: CsrfTokenOption, | |
| 1060 | - | pub session_user: Option<SessionUser>, | |
| 1061 | - | pub owner: String, | |
| 1062 | - | pub repo_name: String, | |
| 1063 | - | pub current_ref: String, | |
| 1064 | - | pub issue: crate::db::DbIssue, | |
| 1065 | - | pub author_username: String, | |
| 1066 | - | pub comments: Vec<crate::db::DbIssueCommentWithAuthor>, | |
| 1067 | - | pub is_owner: bool, | |
| 1068 | - | pub open_issue_count: i64, | |
| 1069 | - | /// Email address for submitting new issues. | |
| 1070 | - | pub email_address: String, | |
| 1071 | - | } | |
| 1072 | - | ||
| 1073 | - | /// Repository settings page (owner only). | |
| 1074 | - | #[derive(Template)] | |
| 1075 | - | #[template(path = "pages/git/settings.html")] | |
| 1076 | - | pub struct GitRepoSettingsTemplate { | |
| 1077 | - | pub csrf_token: CsrfTokenOption, | |
| 1078 | - | pub session_user: Option<SessionUser>, | |
| 1079 | - | pub owner: String, | |
| 1080 | - | pub repo_name: String, | |
| 1081 | - | pub current_ref: String, | |
| 1082 | - | pub repo: crate::db::DbGitRepo, | |
| 1083 | - | pub open_issue_count: i64, | |
| 1084 | - | /// Owner's projects for the link dropdown. | |
| 1085 | - | pub projects: Vec<crate::db::DbProject>, | |
| 1086 | - | /// ID of the currently linked project as a string (for dropdown comparison). | |
| 1087 | - | pub linked_project_id: String, | |
| 1088 | - | } |