max / makenotwork
31 files changed,
+4443 insertions,
-2395 deletions
| @@ -969,4 +969,156 @@ whois_check_interval_secs = 43200 | |||
| 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 | } |
| @@ -3508,7 +3508,7 @@ dependencies = [ | |||
| 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 |
| @@ -1,1501 +0,0 @@ | |||
| 1 | - | //! Item CRUD: creation, listing, text body updates, and ownership lookups. | |
| 2 | - | ||
| 3 | - | use sqlx::PgPool; | |
| 4 | - | ||
| 5 | - | use super::enums::{AiTier, ItemType}; | |
| 6 | - | use super::models::*; | |
| 7 | - | use super::{ItemId, MtThreadId, PriceCents, ProjectId, UserId}; | |
| 8 | - | use crate::error::Result; | |
| 9 | - | ||
| 10 | - | /// Insert a new item into a project and return the created row. | |
| 11 | - | /// | |
| 12 | - | /// Auto-generates a URL-safe slug from the title. If the slug collides with | |
| 13 | - | /// an existing item in the same project, appends a counter suffix. | |
| 14 | - | #[allow(clippy::too_many_arguments)] | |
| 15 | - | #[tracing::instrument(skip_all)] | |
| 16 | - | pub async fn create_item( | |
| 17 | - | pool: &PgPool, | |
| 18 | - | project_id: ProjectId, | |
| 19 | - | title: &str, | |
| 20 | - | description: Option<&str>, | |
| 21 | - | price_cents: PriceCents, | |
| 22 | - | item_type: ItemType, | |
| 23 | - | ai_tier: AiTier, | |
| 24 | - | ai_disclosure: Option<&str>, | |
| 25 | - | ) -> Result<DbItem> { | |
| 26 | - | let mut slug = crate::helpers::slugify(title); | |
| 27 | - | ||
| 28 | - | // Check for collision and append counter if needed | |
| 29 | - | if item_slug_exists(pool, project_id, &slug).await? { | |
| 30 | - | let base = slug.clone(); | |
| 31 | - | let mut counter = 2u32; | |
| 32 | - | loop { | |
| 33 | - | slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter)); | |
| 34 | - | if !item_slug_exists(pool, project_id, &slug).await? { | |
| 35 | - | break; | |
| 36 | - | } | |
| 37 | - | counter += 1; | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // Retry loop for TOCTOU race on slug uniqueness | |
| 42 | - | let base_slug = slug.clone(); | |
| 43 | - | let mut suffix = 1u32; | |
| 44 | - | let item = loop { | |
| 45 | - | match sqlx::query_as::<_, DbItem>( | |
| 46 | - | r#" | |
| 47 | - | INSERT INTO items (project_id, title, description, price_cents, item_type, slug, ai_tier, ai_disclosure) | |
| 48 | - | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) | |
| 49 | - | RETURNING * | |
| 50 | - | "#, | |
| 51 | - | ) | |
| 52 | - | .bind(project_id) | |
| 53 | - | .bind(title) | |
| 54 | - | .bind(description) | |
| 55 | - | .bind(price_cents) | |
| 56 | - | .bind(item_type) | |
| 57 | - | .bind(&slug) | |
| 58 | - | .bind(ai_tier) | |
| 59 | - | .bind(ai_disclosure) | |
| 60 | - | .fetch_one(pool) | |
| 61 | - | .await | |
| 62 | - | { | |
| 63 | - | Ok(item) => break item, | |
| 64 | - | Err(sqlx::Error::Database(db_err)) | |
| 65 | - | if db_err.code().as_deref() == Some("23505") && suffix < 100 => | |
| 66 | - | { | |
| 67 | - | suffix += 1; | |
| 68 | - | slug = super::validated_types::Slug::from_trusted( | |
| 69 | - | format!("{}-{}", base_slug, suffix), | |
| 70 | - | ); | |
| 71 | - | continue; | |
| 72 | - | } | |
| 73 | - | Err(e) => return Err(e.into()), | |
| 74 | - | } | |
| 75 | - | }; | |
| 76 | - | ||
| 77 | - | Ok(item) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | /// Check whether a slug already exists for a given project. | |
| 81 | - | async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>( | |
| 82 | - | executor: E, | |
| 83 | - | project_id: ProjectId, | |
| 84 | - | slug: &super::validated_types::Slug, | |
| 85 | - | ) -> Result<bool> { | |
| 86 | - | let exists: bool = sqlx::query_scalar( | |
| 87 | - | "SELECT EXISTS(SELECT 1 FROM items WHERE project_id = $1 AND slug = $2)", | |
| 88 | - | ) | |
| 89 | - | .bind(project_id) | |
| 90 | - | .bind(slug) | |
| 91 | - | .fetch_one(executor) | |
| 92 | - | .await?; | |
| 93 | - | ||
| 94 | - | Ok(exists) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | /// Fetch an item by primary key. Returns `None` if not found. | |
| 98 | - | #[tracing::instrument(skip_all)] | |
| 99 | - | pub async fn get_item_by_id(pool: &PgPool, id: ItemId) -> Result<Option<DbItem>> { | |
| 100 | - | let item = sqlx::query_as::<_, DbItem>("SELECT * FROM items WHERE id = $1") | |
| 101 | - | .bind(id) | |
| 102 | - | .fetch_optional(pool) | |
| 103 | - | .await?; | |
| 104 | - | ||
| 105 | - | Ok(item) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | /// Fetch titles for a batch of item IDs. Returns (item_id, title) pairs. | |
| 109 | - | #[tracing::instrument(skip_all)] | |
| 110 | - | pub async fn get_item_titles_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, String)>> { | |
| 111 | - | if ids.is_empty() { | |
| 112 | - | return Ok(vec![]); | |
| 113 | - | } | |
| 114 | - | let rows: Vec<(ItemId, String)> = sqlx::query_as( | |
| 115 | - | "SELECT id, title FROM items WHERE id = ANY($1)", | |
| 116 | - | ) | |
| 117 | - | .bind(ids) | |
| 118 | - | .fetch_all(pool) | |
| 119 | - | .await?; | |
| 120 | - | ||
| 121 | - | Ok(rows) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | /// Fetch project_id for a batch of item IDs. Returns (item_id, project_id) pairs. | |
| 125 | - | /// | |
| 126 | - | /// Used by bulk operations to verify all items belong to the same project in one query. | |
| 127 | - | #[tracing::instrument(skip_all)] | |
| 128 | - | pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, super::ProjectId)>> { | |
| 129 | - | if ids.is_empty() { | |
| 130 | - | return Ok(vec![]); | |
| 131 | - | } | |
| 132 | - | let rows: Vec<(ItemId, super::ProjectId)> = sqlx::query_as( | |
| 133 | - | "SELECT id, project_id FROM items WHERE id = ANY($1)", | |
| 134 | - | ) | |
| 135 | - | .bind(ids) | |
| 136 | - | .fetch_all(pool) | |
| 137 | - | .await?; | |
| 138 | - | ||
| 139 | - | Ok(rows) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | /// List all items in a project, ordered by sort_order then newest. | |
| 143 | - | /// | |
| 144 | - | /// Capped at 500 as a safety limit. | |
| 145 | - | #[tracing::instrument(skip_all)] | |
| 146 | - | pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> { | |
| 147 | - | let items = sqlx::query_as::<_, DbItem>( | |
| 148 | - | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 149 | - | ) | |
| 150 | - | .bind(project_id) | |
| 151 | - | .fetch_all(pool) | |
| 152 | - | .await?; | |
| 153 | - | ||
| 154 | - | Ok(items) | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | /// List all items across all projects owned by a user, newest first. | |
| 158 | - | /// | |
| 159 | - | /// Capped at 500 as a safety limit. | |
| 160 | - | #[tracing::instrument(skip_all)] | |
| 161 | - | pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbItem>> { | |
| 162 | - | let items = sqlx::query_as::<_, DbItem>( | |
| 163 | - | r#" | |
| 164 | - | SELECT i.* FROM items i | |
| 165 | - | JOIN projects p ON i.project_id = p.id | |
| 166 | - | WHERE p.user_id = $1 AND i.deleted_at IS NULL | |
| 167 | - | ORDER BY i.created_at DESC | |
| 168 | - | LIMIT 500 | |
| 169 | - | "#, | |
| 170 | - | ) | |
| 171 | - | .bind(user_id) | |
| 172 | - | .fetch_all(pool) | |
| 173 | - | .await?; | |
| 174 | - | ||
| 175 | - | Ok(items) | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | /// Count items per project for all projects owned by a user. | |
| 179 | - | /// | |
| 180 | - | /// Returns `(project_id, count)` tuples. Used by the CLI to avoid N+1 queries. | |
| 181 | - | #[tracing::instrument(skip_all)] | |
| 182 | - | pub async fn count_items_by_user_projects( | |
| 183 | - | pool: &PgPool, | |
| 184 | - | user_id: UserId, | |
| 185 | - | ) -> Result<Vec<(ProjectId, i64)>> { | |
| 186 | - | let rows: Vec<(ProjectId, i64)> = sqlx::query_as( | |
| 187 | - | r#" | |
| 188 | - | SELECT i.project_id, COUNT(*) AS cnt | |
| 189 | - | FROM items i | |
| 190 | - | JOIN projects p ON i.project_id = p.id | |
| 191 | - | WHERE p.user_id = $1 AND i.deleted_at IS NULL | |
| 192 | - | GROUP BY i.project_id | |
| 193 | - | "#, | |
| 194 | - | ) | |
| 195 | - | .bind(user_id) | |
| 196 | - | .fetch_all(pool) | |
| 197 | - | .await?; | |
| 198 | - | ||
| 199 | - | Ok(rows) | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | /// List only public items in a project, ordered by sort_order then newest. | |
| 203 | - | /// | |
| 204 | - | /// Capped at 500 as a safety limit. | |
| 205 | - | #[tracing::instrument(skip_all)] | |
| 206 | - | pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> { | |
| 207 | - | let items = sqlx::query_as::<_, DbItem>( | |
| 208 | - | "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 209 | - | ) | |
| 210 | - | .bind(project_id) | |
| 211 | - | .fetch_all(pool) | |
| 212 | - | .await?; | |
| 213 | - | ||
| 214 | - | Ok(items) | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | /// Partially update an item's fields (COALESCE keeps existing values when `None`). | |
| 218 | - | /// | |
| 219 | - | /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule, | |
| 220 | - | /// `Some(Some(dt))` = set schedule. | |
| 221 | - | #[allow(clippy::too_many_arguments)] | |
| 222 | - | #[tracing::instrument(skip_all)] | |
| 223 | - | pub async fn update_item( | |
| 224 | - | pool: &PgPool, | |
| 225 | - | id: ItemId, | |
| 226 | - | user_id: UserId, | |
| 227 | - | title: Option<&str>, | |
| 228 | - | description: Option<&str>, | |
| 229 | - | price_cents: Option<PriceCents>, | |
| 230 | - | item_type: Option<ItemType>, | |
| 231 | - | is_public: Option<bool>, | |
| 232 | - | pwyw_enabled: Option<bool>, | |
| 233 | - | pwyw_min_cents: Option<PriceCents>, | |
| 234 | - | publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>, | |
| 235 | - | web_only: Option<bool>, | |
| 236 | - | ai_tier: Option<AiTier>, | |
| 237 | - | ai_disclosure: Option<Option<&str>>, | |
| 238 | - | ) -> Result<DbItem> { | |
| 239 | - | // Flatten the double-Option: if outer is None, pass current DB value (via SQL CASE). | |
| 240 | - | // $10 = whether to update publish_at, $11 = the new value (NULL to clear). | |
| 241 | - | let update_publish_at = publish_at.is_some(); | |
| 242 | - | let publish_at_value = publish_at.flatten(); | |
| 243 | - | ||
| 244 | - | // ai_disclosure uses the same double-Option pattern as publish_at: | |
| 245 | - | // None = no change, Some(None) = clear, Some(Some(text)) = set. | |
| 246 | - | let update_ai_disclosure = ai_disclosure.is_some(); | |
| 247 | - | let ai_disclosure_value = ai_disclosure.flatten(); | |
| 248 | - | ||
| 249 | - | let item = sqlx::query_as::<_, DbItem>( | |
| 250 | - | r#" | |
| 251 | - | UPDATE items | |
| 252 | - | SET title = COALESCE($3, title), | |
| 253 | - | description = COALESCE($4, description), | |
| 254 | - | price_cents = COALESCE($5, price_cents), | |
| 255 | - | item_type = COALESCE($6, item_type), | |
| 256 | - | is_public = CASE WHEN removed_by_admin AND $7 = true THEN false ELSE COALESCE($7, is_public) END, | |
| 257 | - | pwyw_enabled = COALESCE($8, pwyw_enabled), | |
| 258 | - | pwyw_min_cents = COALESCE($9, pwyw_min_cents), | |
| 259 | - | publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END, | |
| 260 | - | web_only = COALESCE($12, web_only), | |
| 261 | - | ai_tier = COALESCE($13, ai_tier), | |
| 262 | - | ai_disclosure = CASE WHEN $14 THEN $15 ELSE ai_disclosure END | |
| 263 | - | WHERE id = $1 | |
| 264 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 265 | - | RETURNING * | |
| 266 | - | "#, | |
| 267 | - | ) | |
| 268 | - | .bind(id) | |
| 269 | - | .bind(user_id) | |
| 270 | - | .bind(title) | |
| 271 | - | .bind(description) | |
| 272 | - | .bind(price_cents) | |
| 273 | - | .bind(item_type) | |
| 274 | - | .bind(is_public) | |
| 275 | - | .bind(pwyw_enabled) | |
| 276 | - | .bind(pwyw_min_cents) | |
| 277 | - | .bind(update_publish_at) | |
| 278 | - | .bind(publish_at_value) | |
| 279 | - | .bind(web_only) | |
| 280 | - | .bind(ai_tier) | |
| 281 | - | .bind(update_ai_disclosure) | |
| 282 | - | .bind(ai_disclosure_value) | |
| 283 | - | .fetch_one(pool) | |
| 284 | - | .await?; | |
| 285 | - | ||
| 286 | - | Ok(item) | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | /// Publish all items whose scheduled publish time has passed. | |
| 290 | - | /// | |
| 291 | - | /// Atomically sets `is_public = true` and clears `publish_at`, returning the | |
| 292 | - | /// newly published items so the caller can send release announcements. | |
| 293 | - | #[tracing::instrument(skip_all)] | |
| 294 | - | pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> { | |
| 295 | - | let items = sqlx::query_as::<_, DbItem>( | |
| 296 | - | r#" | |
| 297 | - | UPDATE items | |
| 298 | - | SET is_public = true, publish_at = NULL, updated_at = NOW() | |
| 299 | - | WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false AND removed_by_admin = false | |
| 300 | - | RETURNING * | |
| 301 | - | "#, | |
| 302 | - | ) | |
| 303 | - | .fetch_all(pool) | |
| 304 | - | .await?; | |
| 305 | - | ||
| 306 | - | Ok(items) | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | /// Soft-delete an item (sets deleted_at, recoverable for 7 days). | |
| 310 | - | #[tracing::instrument(skip_all)] | |
| 311 | - | pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> { | |
| 312 | - | sqlx::query( | |
| 313 | - | r#" | |
| 314 | - | UPDATE items SET deleted_at = NOW(), is_public = false | |
| 315 | - | WHERE id = $1 AND deleted_at IS NULL | |
| 316 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 317 | - | "#, | |
| 318 | - | ) | |
| 319 | - | .bind(id) | |
| 320 | - | .bind(user_id) | |
| 321 | - | .execute(pool) | |
| 322 | - | .await?; | |
| 323 | - | ||
| 324 | - | Ok(()) | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | /// Restore a soft-deleted item. | |
| 328 | - | #[tracing::instrument(skip_all)] | |
| 329 | - | pub async fn restore_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<bool> { | |
| 330 | - | let result = sqlx::query( | |
| 331 | - | r#" | |
| 332 | - | UPDATE items SET deleted_at = NULL | |
| 333 | - | WHERE id = $1 AND deleted_at IS NOT NULL | |
| 334 | - | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 335 | - | "#, | |
| 336 | - | ) | |
| 337 | - | .bind(id) | |
| 338 | - | .bind(user_id) | |
| 339 | - | .execute(pool) | |
| 340 | - | .await?; | |
| 341 | - | ||
| 342 | - | Ok(result.rows_affected() > 0) | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | /// Get soft-deleted items for a project (for the "Recently Deleted" section). | |
| 346 | - | #[tracing::instrument(skip_all)] | |
| 347 | - | pub async fn get_deleted_items_by_project( | |
| 348 | - | pool: &PgPool, | |
| 349 | - | project_id: ProjectId, | |
| 350 | - | ) -> Result<Vec<DbItem>> { | |
| 351 | - | let items = sqlx::query_as::<_, DbItem>( | |
| 352 | - | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 500", | |
| 353 | - | ) | |
| 354 | - | .bind(project_id) | |
| 355 | - | .fetch_all(pool) | |
| 356 | - | .await?; | |
| 357 | - | ||
| 358 | - | Ok(items) | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | /// Collect S3 keys from items that are about to be purged (soft-deleted >7 days). | |
| 362 | - | /// Returns all non-null S3 keys (audio, cover, video) so they can be deleted | |
| 363 | - | /// from S3 before the DB rows are removed. | |
| 364 | - | #[tracing::instrument(skip_all)] | |
| 365 | - | pub async fn get_expired_deleted_item_s3_keys(pool: &PgPool) -> Result<Vec<String>> { | |
| 366 | - | let keys: Vec<String> = sqlx::query_scalar( | |
| 367 | - | r#" | |
| 368 | - | SELECT k FROM ( | |
| 369 | - | SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k | |
| 370 | - | FROM items | |
| 371 | - | WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days' | |
| 372 | - | AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL) | |
| 373 | - | ) sub | |
| 374 | - | "#, | |
| 375 | - | ) | |
| 376 | - | .fetch_all(pool) | |
| 377 | - | .await?; | |
| 378 | - | ||
| 379 | - | Ok(keys) | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | /// Collect S3 keys from versions belonging to items about to be purged. | |
| 383 | - | /// Must be called before purge since CASCADE delete destroys version rows. | |
| 384 | - | #[tracing::instrument(skip_all)] | |
| 385 | - | pub async fn get_expired_deleted_item_version_s3_keys(pool: &PgPool) -> Result<Vec<String>> { | |
| 386 | - | let keys: Vec<String> = sqlx::query_scalar( | |
| 387 | - | r#" | |
| 388 | - | SELECT v.s3_key | |
| 389 | - | FROM versions v | |
| 390 | - | JOIN items i ON v.item_id = i.id | |
| 391 | - | WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days' | |
| 392 | - | AND v.s3_key IS NOT NULL | |
| 393 | - | "#, | |
| 394 | - | ) | |
| 395 | - | .fetch_all(pool) | |
| 396 | - | .await?; | |
| 397 | - | ||
| 398 | - | Ok(keys) | |
| 399 | - | } | |
| 400 | - | ||
| 401 | - | /// Sum total file sizes per user for items about to be purged, including version files. | |
| 402 | - | /// Returns (user_id, total_bytes) pairs for storage decrement. | |
| 403 | - | #[tracing::instrument(skip_all)] | |
| 404 | - | pub async fn get_expired_deleted_item_storage_by_user(pool: &PgPool) -> Result<Vec<(super::UserId, i64)>> { | |
| 405 | - | let rows: Vec<(super::UserId, i64)> = sqlx::query_as( | |
| 406 | - | r#" | |
| 407 | - | SELECT p.user_id, | |
| 408 | - | COALESCE(SUM( | |
| 409 | - | COALESCE(i.audio_file_size_bytes, 0) + | |
| 410 | - | COALESCE(i.cover_file_size_bytes, 0) + | |
| 411 | - | COALESCE(i.video_file_size_bytes, 0) + | |
| 412 | - | COALESCE(ver.version_bytes, 0) | |
| 413 | - | ), 0)::BIGINT AS total_bytes | |
| 414 | - | FROM items i | |
| 415 | - | JOIN projects p ON i.project_id = p.id | |
| 416 | - | LEFT JOIN LATERAL ( | |
| 417 | - | SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes | |
| 418 | - | FROM versions v | |
| 419 | - | WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL | |
| 420 | - | ) ver ON true | |
| 421 | - | WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days' | |
| 422 | - | GROUP BY p.user_id | |
| 423 | - | "#, | |
| 424 | - | ) | |
| 425 | - | .fetch_all(pool) | |
| 426 | - | .await?; | |
| 427 | - | ||
| 428 | - | Ok(rows) | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | /// Collect all S3 keys from items belonging to a project (audio, cover, video). | |
| 432 | - | #[tracing::instrument(skip_all)] | |
| 433 | - | pub async fn get_project_item_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> { | |
| 434 | - | let keys: Vec<String> = sqlx::query_scalar( | |
| 435 | - | r#" | |
| 436 | - | SELECT k FROM ( | |
| 437 | - | SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k | |
| 438 | - | FROM items | |
| 439 | - | WHERE project_id = $1 | |
| 440 | - | AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL) | |
| 441 | - | ) sub | |
| 442 | - | "#, | |
| 443 | - | ) | |
| 444 | - | .bind(project_id) | |
| 445 | - | .fetch_all(pool) | |
| 446 | - | .await?; | |
| 447 | - | ||
| 448 | - | Ok(keys) | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | /// Collect S3 keys from versions belonging to items in a project. | |
| 452 | - | #[tracing::instrument(skip_all)] | |
| 453 | - | pub async fn get_project_version_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> { | |
| 454 | - | let keys: Vec<String> = sqlx::query_scalar( | |
| 455 | - | r#" | |
| 456 | - | SELECT v.s3_key | |
| 457 | - | FROM versions v | |
| 458 | - | JOIN items i ON v.item_id = i.id | |
| 459 | - | WHERE i.project_id = $1 AND v.s3_key IS NOT NULL | |
| 460 | - | "#, | |
| 461 | - | ) | |
| 462 | - | .bind(project_id) | |
| 463 | - | .fetch_all(pool) | |
| 464 | - | .await?; | |
| 465 | - | ||
| 466 | - | Ok(keys) | |
| 467 | - | } | |
| 468 | - | ||
| 469 | - | /// Sum total file sizes for all items and versions in a project. | |
| 470 | - | #[tracing::instrument(skip_all)] | |
| 471 | - | pub async fn get_project_storage_bytes(pool: &PgPool, project_id: super::ProjectId) -> Result<i64> { | |
| 472 | - | let total: i64 = sqlx::query_scalar( | |
| 473 | - | r#" | |
| 474 | - | SELECT COALESCE(SUM( | |
| 475 | - | COALESCE(i.audio_file_size_bytes, 0) + | |
| 476 | - | COALESCE(i.cover_file_size_bytes, 0) + | |
| 477 | - | COALESCE(i.video_file_size_bytes, 0) + | |
| 478 | - | COALESCE(ver.version_bytes, 0) | |
| 479 | - | ), 0)::BIGINT | |
| 480 | - | FROM items i | |
| 481 | - | LEFT JOIN LATERAL ( | |
| 482 | - | SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes | |
| 483 | - | FROM versions v | |
| 484 | - | WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL | |
| 485 | - | ) ver ON true | |
| 486 | - | WHERE i.project_id = $1 | |
| 487 | - | "#, | |
| 488 | - | ) | |
| 489 | - | .bind(project_id) | |
| 490 | - | .fetch_one(pool) | |
| 491 | - | .await?; | |
| 492 | - | ||
| 493 | - | Ok(total) | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | /// Permanently delete items that were soft-deleted more than 7 days ago. | |
| 497 | - | #[tracing::instrument(skip_all)] | |
| 498 | - | pub async fn purge_expired_deleted_items(pool: &PgPool) -> Result<u64> { | |
| 499 | - | let result = sqlx::query( | |
| 500 | - | "DELETE FROM items WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days'", |
Lines truncated
| @@ -0,0 +1,311 @@ | |||
| 1 | + | //! Bulk and structural item operations: cross-project moves, batch | |
| 2 | + | //! publish/unpublish/delete/repricing/tagging, and item duplication. | |
| 3 | + | ||
| 4 | + | use sqlx::PgPool; | |
| 5 | + | ||
| 6 | + | use crate::db::models::*; | |
| 7 | + | use crate::db::{ItemId, PriceCents, ProjectId, UserId}; | |
| 8 | + | use crate::error::Result; | |
| 9 | + | ||
| 10 | + | pub async fn move_item( | |
| 11 | + | pool: &PgPool, | |
| 12 | + | project_id: ProjectId, | |
| 13 | + | user_id: UserId, | |
| 14 | + | item_id: ItemId, | |
| 15 | + | direction: &str, | |
| 16 | + | ) -> Result<()> { | |
| 17 | + | let mut tx = pool.begin().await?; | |
| 18 | + | ||
| 19 | + | // Lock and fetch item IDs in display order (scoped to projects owned by user) | |
| 20 | + | let item_ids: Vec<ItemId> = sqlx::query_scalar( | |
| 21 | + | r#" | |
| 22 | + | SELECT id FROM items | |
| 23 | + | WHERE project_id = $1 | |
| 24 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 25 | + | ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE | |
| 26 | + | "#, | |
| 27 | + | ) | |
| 28 | + | .bind(project_id) | |
| 29 | + | .bind(user_id) | |
| 30 | + | .fetch_all(&mut *tx) | |
| 31 | + | .await?; | |
| 32 | + | ||
| 33 | + | let Some(pos) = item_ids.iter().position(|id| *id == item_id) else { | |
| 34 | + | return Ok(()); | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | let swap_pos = match direction { | |
| 38 | + | "up" if pos > 0 => pos - 1, | |
| 39 | + | "down" if pos + 1 < item_ids.len() => pos + 1, | |
| 40 | + | _ => return Ok(()), | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | // Normalize all sort_orders, swapping the target pair (single batch UPDATE) | |
| 44 | + | let mut ids = Vec::with_capacity(item_ids.len()); | |
| 45 | + | let mut orders = Vec::with_capacity(item_ids.len()); | |
| 46 | + | for (i, id) in item_ids.iter().enumerate() { | |
| 47 | + | ids.push(*id); | |
| 48 | + | orders.push(if i == pos { | |
| 49 | + | swap_pos as i32 | |
| 50 | + | } else if i == swap_pos { | |
| 51 | + | pos as i32 | |
| 52 | + | } else { | |
| 53 | + | i as i32 | |
| 54 | + | }); | |
| 55 | + | } | |
| 56 | + | sqlx::query( | |
| 57 | + | "UPDATE items SET sort_order = batch.ord FROM UNNEST($1::UUID[], $2::INT[]) AS batch(id, ord) WHERE items.id = batch.id", | |
| 58 | + | ) | |
| 59 | + | .bind(&ids) | |
| 60 | + | .bind(&orders) | |
| 61 | + | .execute(&mut *tx) | |
| 62 | + | .await?; | |
| 63 | + | ||
| 64 | + | tx.commit().await?; | |
| 65 | + | Ok(()) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Bulk-publish items: set `is_public = true` and clear any scheduled `publish_at`. | |
| 69 | + | /// | |
| 70 | + | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 71 | + | #[tracing::instrument(skip_all)] | |
| 72 | + | pub async fn bulk_publish( | |
| 73 | + | pool: &PgPool, | |
| 74 | + | item_ids: &[ItemId], | |
| 75 | + | project_id: ProjectId, | |
| 76 | + | user_id: UserId, | |
| 77 | + | ) -> Result<u64> { | |
| 78 | + | let result = sqlx::query( | |
| 79 | + | r#" | |
| 80 | + | UPDATE items | |
| 81 | + | SET is_public = true, publish_at = NULL, updated_at = NOW() | |
| 82 | + | WHERE id = ANY($1) AND project_id = $2 | |
| 83 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 84 | + | AND removed_by_admin = false | |
| 85 | + | "#, | |
| 86 | + | ) | |
| 87 | + | .bind(item_ids) | |
| 88 | + | .bind(project_id) | |
| 89 | + | .bind(user_id) | |
| 90 | + | .execute(pool) | |
| 91 | + | .await?; | |
| 92 | + | ||
| 93 | + | Ok(result.rows_affected()) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Bulk-unpublish items: set `is_public = false`. | |
| 97 | + | /// | |
| 98 | + | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 99 | + | #[tracing::instrument(skip_all)] | |
| 100 | + | pub async fn bulk_unpublish( | |
| 101 | + | pool: &PgPool, | |
| 102 | + | item_ids: &[ItemId], | |
| 103 | + | project_id: ProjectId, | |
| 104 | + | user_id: UserId, | |
| 105 | + | ) -> Result<u64> { | |
| 106 | + | let result = sqlx::query( | |
| 107 | + | r#" | |
| 108 | + | UPDATE items | |
| 109 | + | SET is_public = false, updated_at = NOW() | |
| 110 | + | WHERE id = ANY($1) AND project_id = $2 | |
| 111 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 112 | + | "#, | |
| 113 | + | ) | |
| 114 | + | .bind(item_ids) | |
| 115 | + | .bind(project_id) | |
| 116 | + | .bind(user_id) | |
| 117 | + | .execute(pool) | |
| 118 | + | .await?; | |
| 119 | + | ||
| 120 | + | Ok(result.rows_affected()) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Soft-delete items from a project (sets deleted_at, recoverable for 7 days). | |
| 124 | + | /// | |
| 125 | + | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 126 | + | #[tracing::instrument(skip_all)] | |
| 127 | + | pub async fn bulk_delete( | |
| 128 | + | pool: &PgPool, | |
| 129 | + | item_ids: &[ItemId], | |
| 130 | + | project_id: ProjectId, | |
| 131 | + | user_id: UserId, | |
| 132 | + | ) -> Result<u64> { | |
| 133 | + | let result = sqlx::query( | |
| 134 | + | r#" | |
| 135 | + | UPDATE items SET deleted_at = NOW(), is_public = false | |
| 136 | + | WHERE id = ANY($1) AND project_id = $2 AND deleted_at IS NULL | |
| 137 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 138 | + | "#, | |
| 139 | + | ) | |
| 140 | + | .bind(item_ids) | |
| 141 | + | .bind(project_id) | |
| 142 | + | .bind(user_id) | |
| 143 | + | .execute(pool) | |
| 144 | + | .await?; | |
| 145 | + | ||
| 146 | + | Ok(result.rows_affected()) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Bulk-update price on selected items. | |
| 150 | + | /// | |
| 151 | + | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 152 | + | #[tracing::instrument(skip_all)] | |
| 153 | + | pub async fn bulk_update_price( | |
| 154 | + | pool: &PgPool, | |
| 155 | + | item_ids: &[ItemId], | |
| 156 | + | project_id: ProjectId, | |
| 157 | + | user_id: UserId, | |
| 158 | + | price_cents: PriceCents, | |
| 159 | + | ) -> Result<u64> { | |
| 160 | + | let result = sqlx::query( | |
| 161 | + | r#" | |
| 162 | + | UPDATE items SET price_cents = $4 | |
| 163 | + | WHERE id = ANY($1) AND project_id = $2 | |
| 164 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 165 | + | "#, | |
| 166 | + | ) | |
| 167 | + | .bind(item_ids) | |
| 168 | + | .bind(project_id) | |
| 169 | + | .bind(user_id) | |
| 170 | + | .bind(price_cents) | |
| 171 | + | .execute(pool) | |
| 172 | + | .await?; | |
| 173 | + | ||
| 174 | + | Ok(result.rows_affected()) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// Bulk-add a tag to selected items (skips duplicates via ON CONFLICT). | |
| 178 | + | /// | |
| 179 | + | /// Returns number of new tag associations created. | |
| 180 | + | #[tracing::instrument(skip_all)] | |
| 181 | + | pub async fn bulk_add_tag( | |
| 182 | + | pool: &PgPool, | |
| 183 | + | item_ids: &[ItemId], | |
| 184 | + | project_id: ProjectId, | |
| 185 | + | user_id: UserId, | |
| 186 | + | tag_id: crate::db::TagId, | |
| 187 | + | ) -> Result<u64> { | |
| 188 | + | // Verify all items belong to the project owned by this user, | |
| 189 | + | // then insert tag associations for each. | |
| 190 | + | let result = sqlx::query( | |
| 191 | + | r#" | |
| 192 | + | INSERT INTO item_tags (item_id, tag_id) | |
| 193 | + | SELECT i.id, $4 | |
| 194 | + | FROM items i | |
| 195 | + | JOIN projects p ON i.project_id = p.id | |
| 196 | + | WHERE i.id = ANY($1) AND i.project_id = $2 AND p.user_id = $3 | |
| 197 | + | ON CONFLICT (item_id, tag_id) DO NOTHING | |
| 198 | + | "#, | |
| 199 | + | ) | |
| 200 | + | .bind(item_ids) | |
| 201 | + | .bind(project_id) | |
| 202 | + | .bind(user_id) | |
| 203 | + | .bind(tag_id) | |
| 204 | + | .execute(pool) | |
| 205 | + | .await?; | |
| 206 | + | ||
| 207 | + | Ok(result.rows_affected()) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Duplicate an item and its metadata (tags, chapters, content insertion placements). | |
| 211 | + | /// | |
| 212 | + | /// Creates a draft copy with "Copy of …" title. Does not copy versions (S3 files), | |
| 213 | + | /// license keys, download codes, or discount codes. | |
| 214 | + | #[tracing::instrument(skip_all)] | |
| 215 | + | pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) -> Result<DbItem> { | |
| 216 | + | let mut tx = pool.begin().await?; | |
| 217 | + | ||
| 218 | + | // Generate a unique slug for the copy (verify ownership via project) | |
| 219 | + | let source = sqlx::query_as::<_, DbItem>( | |
| 220 | + | "SELECT * FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)", | |
| 221 | + | ) | |
| 222 | + | .bind(source_id) | |
| 223 | + | .bind(user_id) | |
| 224 | + | .fetch_one(&mut *tx) | |
| 225 | + | .await?; | |
| 226 | + | let copy_title = format!("Copy of {}", &source.title); | |
| 227 | + | let copy_title: String = copy_title.chars().take(200).collect(); | |
| 228 | + | let mut slug = crate::helpers::slugify(©_title); | |
| 229 | + | if super::item_slug_exists(&mut *tx, source.project_id, &slug).await? { | |
| 230 | + | let base = slug.clone(); | |
| 231 | + | let mut counter = 2u32; | |
| 232 | + | loop { | |
| 233 | + | if counter > 100 { | |
| 234 | + | return Err(crate::error::AppError::BadRequest( | |
| 235 | + | "Too many copies with similar names. Rename an existing copy first.".to_string(), | |
| 236 | + | )); | |
| 237 | + | } | |
| 238 | + | slug = crate::db::validated_types::Slug::from_trusted(format!("{}-{}", base, counter)); | |
| 239 | + | if !super::item_slug_exists(&mut *tx, source.project_id, &slug).await? { | |
| 240 | + | break; | |
| 241 | + | } | |
| 242 | + | counter += 1; | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // Step 1: Clone item row | |
| 247 | + | let new_item = sqlx::query_as::<_, DbItem>( | |
| 248 | + | r#" | |
| 249 | + | INSERT INTO items ( | |
| 250 | + | project_id, title, description, price_cents, item_type, thumbnail_url, | |
| 251 | + | sort_order, body, word_count, reading_time_minutes, duration_seconds, | |
| 252 | + | episode_number, enable_license_keys, default_max_activations, | |
| 253 | + | pwyw_enabled, pwyw_min_cents, is_public, slug | |
| 254 | + | ) | |
| 255 | + | SELECT | |
| 256 | + | project_id, LEFT('Copy of ' || title, 200), description, price_cents, | |
| 257 | + | item_type, thumbnail_url, sort_order, body, word_count, | |
| 258 | + | reading_time_minutes, duration_seconds, episode_number, | |
| 259 | + | enable_license_keys, default_max_activations, pwyw_enabled, | |
| 260 | + | pwyw_min_cents, false, $2 | |
| 261 | + | FROM items WHERE id = $1 | |
| 262 | + | RETURNING * | |
| 263 | + | "#, | |
| 264 | + | ) | |
| 265 | + | .bind(source_id) | |
| 266 | + | .bind(&slug) | |
| 267 | + | .fetch_one(&mut *tx) | |
| 268 | + | .await?; | |
| 269 | + | ||
| 270 | + | // Step 2: Copy tags | |
| 271 | + | sqlx::query( | |
| 272 | + | r#" | |
| 273 | + | INSERT INTO item_tags (item_id, tag_id, is_primary) | |
| 274 | + | SELECT $2, tag_id, is_primary FROM item_tags WHERE item_id = $1 | |
| 275 | + | "#, | |
| 276 | + | ) | |
| 277 | + | .bind(source_id) | |
| 278 | + | .bind(new_item.id) | |
| 279 | + | .execute(&mut *tx) | |
| 280 | + | .await?; | |
| 281 | + | ||
| 282 | + | // Step 3: Copy chapters | |
| 283 | + | sqlx::query( | |
| 284 | + | r#" | |
| 285 | + | INSERT INTO chapters (item_id, title, start_seconds, sort_order) | |
| 286 | + | SELECT $2, title, start_seconds, sort_order FROM chapters WHERE item_id = $1 | |
| 287 | + | "#, | |
| 288 | + | ) | |
| 289 | + | .bind(source_id) | |
| 290 | + | .bind(new_item.id) | |
| 291 | + | .execute(&mut *tx) | |
| 292 | + | .await?; | |
| 293 | + | ||
| 294 | + | // Step 4: Copy content insertion placements | |
| 295 | + | sqlx::query( | |
| 296 | + | r#" | |
| 297 | + | INSERT INTO content_insertion_placements (item_id, insertion_id, position, offset_ms, sort_order) | |
| 298 | + | SELECT $2, insertion_id, position, offset_ms, sort_order | |
| 299 | + | FROM content_insertion_placements WHERE item_id = $1 | |
| 300 | + | "#, | |
| 301 | + | ) | |
| 302 | + | .bind(source_id) | |
| 303 | + | .bind(new_item.id) | |
| 304 | + | .execute(&mut *tx) | |
| 305 | + | .await?; | |
| 306 | + | ||
| 307 | + | tx.commit().await?; | |
| 308 | + | ||
| 309 | + | Ok(new_item) | |
| 310 | + | } | |
| 311 | + |
| @@ -0,0 +1,198 @@ | |||
| 1 | + | //! Item media metadata: post-upload file-size writebacks and per-content-type | |
| 2 | + | //! S3 key/URL/metadata updates (audio, cover, video). | |
| 3 | + | ||
| 4 | + | use sqlx::PgPool; | |
| 5 | + | ||
| 6 | + | use crate::db::models::*; | |
| 7 | + | use crate::db::{ItemId, UserId}; | |
| 8 | + | use crate::error::Result; | |
| 9 | + | ||
| 10 | + | /// Get the audio, cover, and video file sizes for an item (for storage decrement on delete). | |
| 11 | + | #[tracing::instrument(skip_all)] | |
| 12 | + | pub async fn get_item_file_sizes( | |
| 13 | + | pool: &PgPool, | |
| 14 | + | id: ItemId, | |
| 15 | + | ) -> Result<crate::db::models::ItemFileSizes> { | |
| 16 | + | let row = sqlx::query_as::<_, (Option<i64>, Option<i64>, Option<i64>)>( | |
| 17 | + | "SELECT audio_file_size_bytes, cover_file_size_bytes, video_file_size_bytes FROM items WHERE id = $1", | |
| 18 | + | ) | |
| 19 | + | .bind(id) | |
| 20 | + | .fetch_optional(pool) | |
| 21 | + | .await?; | |
| 22 | + | ||
| 23 | + | match row { | |
| 24 | + | Some((audio, cover, video)) => Ok(crate::db::models::ItemFileSizes { | |
| 25 | + | audio_file_size_bytes: audio, | |
| 26 | + | cover_file_size_bytes: cover, | |
| 27 | + | video_file_size_bytes: video, | |
| 28 | + | }), | |
| 29 | + | None => Ok(crate::db::models::ItemFileSizes { | |
| 30 | + | audio_file_size_bytes: None, | |
| 31 | + | cover_file_size_bytes: None, | |
| 32 | + | video_file_size_bytes: None, | |
| 33 | + | }), | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Update the audio file size on an item (defense-in-depth: verifies ownership). | |
| 38 | + | #[tracing::instrument(skip_all)] | |
| 39 | + | pub async fn update_item_audio_file_size( | |
| 40 | + | pool: &PgPool, | |
| 41 | + | item_id: ItemId, | |
| 42 | + | user_id: UserId, | |
| 43 | + | bytes: i64, | |
| 44 | + | ) -> Result<()> { | |
| 45 | + | sqlx::query( | |
| 46 | + | "UPDATE items SET audio_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 47 | + | ) | |
| 48 | + | .bind(item_id) | |
| 49 | + | .bind(bytes) | |
| 50 | + | .bind(user_id) | |
| 51 | + | .execute(pool) | |
| 52 | + | .await?; | |
| 53 | + | ||
| 54 | + | Ok(()) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Update the cover image URL for an item (defense-in-depth: verifies ownership). | |
| 58 | + | #[tracing::instrument(skip_all)] | |
| 59 | + | pub async fn update_item_cover_image_url( | |
| 60 | + | pool: &PgPool, | |
| 61 | + | item_id: ItemId, | |
| 62 | + | user_id: UserId, | |
| 63 | + | url: &str, | |
| 64 | + | ) -> Result<()> { | |
| 65 | + | sqlx::query( | |
| 66 | + | "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)", | |
| 67 | + | ) | |
| 68 | + | .bind(item_id) | |
| 69 | + | .bind(url) | |
| 70 | + | .bind(user_id) | |
| 71 | + | .execute(pool) | |
| 72 | + | .await?; | |
| 73 | + | ||
| 74 | + | Ok(()) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Atomically update cover image URL, S3 key, and file size in a single UPDATE | |
| 78 | + | /// (defense-in-depth: verifies ownership). | |
| 79 | + | #[tracing::instrument(skip_all)] | |
| 80 | + | pub async fn update_item_cover( | |
| 81 | + | pool: &PgPool, | |
| 82 | + | item_id: ItemId, | |
| 83 | + | user_id: UserId, | |
| 84 | + | url: &str, | |
| 85 | + | s3_key: &str, | |
| 86 | + | file_size_bytes: i64, | |
| 87 | + | ) -> Result<()> { | |
| 88 | + | sqlx::query( | |
| 89 | + | r#"UPDATE items | |
| 90 | + | SET cover_image_url = $2, cover_s3_key = $3, cover_file_size_bytes = $4, updated_at = NOW() | |
| 91 | + | WHERE id = $1 | |
| 92 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $5)"#, | |
| 93 | + | ) | |
| 94 | + | .bind(item_id) | |
| 95 | + | .bind(url) | |
| 96 | + | .bind(s3_key) | |
| 97 | + | .bind(file_size_bytes) | |
| 98 | + | .bind(user_id) | |
| 99 | + | .execute(pool) | |
| 100 | + | .await?; | |
| 101 | + | ||
| 102 | + | Ok(()) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Update the cover file size on an item (defense-in-depth: verifies ownership). | |
| 106 | + | #[tracing::instrument(skip_all)] | |
| 107 | + | pub async fn update_item_cover_file_size( | |
| 108 | + | pool: &PgPool, | |
| 109 | + | item_id: ItemId, | |
| 110 | + | user_id: UserId, | |
| 111 | + | bytes: i64, | |
| 112 | + | ) -> Result<()> { | |
| 113 | + | sqlx::query( | |
| 114 | + | "UPDATE items SET cover_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 115 | + | ) | |
| 116 | + | .bind(item_id) | |
| 117 | + | .bind(bytes) | |
| 118 | + | .bind(user_id) | |
| 119 | + | .execute(pool) | |
| 120 | + | .await?; | |
| 121 | + | ||
| 122 | + | Ok(()) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// Update the video S3 key for an item (defense-in-depth: verifies ownership). | |
| 126 | + | #[tracing::instrument(skip_all)] | |
| 127 | + | pub async fn update_item_video_s3_key( | |
| 128 | + | pool: &PgPool, | |
| 129 | + | item_id: ItemId, | |
| 130 | + | user_id: UserId, | |
| 131 | + | s3_key: &str, | |
| 132 | + | ) -> Result<DbItem> { | |
| 133 | + | let item = sqlx::query_as::<_, DbItem>( | |
| 134 | + | r#" | |
| 135 | + | UPDATE items | |
| 136 | + | SET video_s3_key = $2, updated_at = NOW() | |
| 137 | + | WHERE id = $1 | |
| 138 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 139 | + | RETURNING * | |
| 140 | + | "#, | |
| 141 | + | ) | |
| 142 | + | .bind(item_id) | |
| 143 | + | .bind(s3_key) | |
| 144 | + | .bind(user_id) | |
| 145 | + | .fetch_one(pool) | |
| 146 | + | .await?; | |
| 147 | + | ||
| 148 | + | Ok(item) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Update the video file size on an item (defense-in-depth: verifies ownership). | |
| 152 | + | #[tracing::instrument(skip_all)] | |
| 153 | + | pub async fn update_item_video_file_size( | |
| 154 | + | pool: &PgPool, | |
| 155 | + | item_id: ItemId, | |
| 156 | + | user_id: UserId, | |
| 157 | + | bytes: i64, | |
| 158 | + | ) -> Result<()> { | |
| 159 | + | sqlx::query( | |
| 160 | + | "UPDATE items SET video_file_size_bytes = $2 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 161 | + | ) | |
| 162 | + | .bind(item_id) | |
| 163 | + | .bind(bytes) | |
| 164 | + | .bind(user_id) | |
| 165 | + | .execute(pool) | |
| 166 | + | .await?; | |
| 167 | + | ||
| 168 | + | Ok(()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Update video metadata (duration, resolution) on an item (defense-in-depth: verifies ownership). | |
| 172 | + | #[tracing::instrument(skip_all)] | |
| 173 | + | pub async fn update_item_video_metadata( | |
| 174 | + | pool: &PgPool, | |
| 175 | + | item_id: ItemId, | |
| 176 | + | user_id: UserId, | |
| 177 | + | duration_seconds: Option<i32>, | |
| 178 | + | width: Option<i32>, | |
| 179 | + | height: Option<i32>, | |
| 180 | + | ) -> Result<()> { | |
| 181 | + | sqlx::query( | |
| 182 | + | r#" | |
| 183 | + | UPDATE items | |
| 184 | + | SET video_duration_seconds = $2, video_width = $3, video_height = $4, updated_at = NOW() | |
| 185 | + | WHERE id = $1 | |
| 186 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $5) | |
| 187 | + | "#, | |
| 188 | + | ) | |
| 189 | + | .bind(item_id) | |
| 190 | + | .bind(duration_seconds) | |
| 191 | + | .bind(width) | |
| 192 | + | .bind(height) | |
| 193 | + | .bind(user_id) | |
| 194 | + | .execute(pool) | |
| 195 | + | .await?; | |
| 196 | + | ||
| 197 | + | Ok(()) | |
| 198 | + | } |
| @@ -0,0 +1,1014 @@ | |||
| 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::*; | |
| 12 | + | ||
| 13 | + | use sqlx::PgPool; | |
| 14 | + | ||
| 15 | + | use super::enums::{AiTier, ItemType}; | |
| 16 | + | use super::models::*; | |
| 17 | + | use super::{ItemId, MtThreadId, PriceCents, ProjectId, UserId}; | |
| 18 | + | use crate::error::Result; | |
| 19 | + | ||
| 20 | + | /// Insert a new item into a project and return the created row. | |
| 21 | + | /// | |
| 22 | + | /// Auto-generates a URL-safe slug from the title. If the slug collides with | |
| 23 | + | /// an existing item in the same project, appends a counter suffix. | |
| 24 | + | #[allow(clippy::too_many_arguments)] | |
| 25 | + | #[tracing::instrument(skip_all)] | |
| 26 | + | pub async fn create_item( | |
| 27 | + | pool: &PgPool, | |
| 28 | + | project_id: ProjectId, | |
| 29 | + | title: &str, | |
| 30 | + | description: Option<&str>, | |
| 31 | + | price_cents: PriceCents, | |
| 32 | + | item_type: ItemType, | |
| 33 | + | ai_tier: AiTier, | |
| 34 | + | ai_disclosure: Option<&str>, | |
| 35 | + | ) -> Result<DbItem> { | |
| 36 | + | let mut slug = crate::helpers::slugify(title); | |
| 37 | + | ||
| 38 | + | // Check for collision and append counter if needed | |
| 39 | + | if item_slug_exists(pool, project_id, &slug).await? { | |
| 40 | + | let base = slug.clone(); | |
| 41 | + | let mut counter = 2u32; | |
| 42 | + | loop { | |
| 43 | + | slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter)); | |
| 44 | + | if !item_slug_exists(pool, project_id, &slug).await? { | |
| 45 | + | break; | |
| 46 | + | } | |
| 47 | + | counter += 1; | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Retry loop for TOCTOU race on slug uniqueness | |
| 52 | + | let base_slug = slug.clone(); | |
| 53 | + | let mut suffix = 1u32; | |
| 54 | + | let item = loop { | |
| 55 | + | match sqlx::query_as::<_, DbItem>( | |
| 56 | + | r#" | |
| 57 | + | INSERT INTO items (project_id, title, description, price_cents, item_type, slug, ai_tier, ai_disclosure) | |
| 58 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) | |
| 59 | + | RETURNING * | |
| 60 | + | "#, | |
| 61 | + | ) | |
| 62 | + | .bind(project_id) | |
| 63 | + | .bind(title) | |
| 64 | + | .bind(description) | |
| 65 | + | .bind(price_cents) | |
| 66 | + | .bind(item_type) | |
| 67 | + | .bind(&slug) | |
| 68 | + | .bind(ai_tier) | |
| 69 | + | .bind(ai_disclosure) | |
| 70 | + | .fetch_one(pool) | |
| 71 | + | .await | |
| 72 | + | { | |
| 73 | + | Ok(item) => break item, | |
| 74 | + | Err(sqlx::Error::Database(db_err)) | |
| 75 | + | if db_err.code().as_deref() == Some("23505") && suffix < 100 => | |
| 76 | + | { | |
| 77 | + | suffix += 1; | |
| 78 | + | slug = super::validated_types::Slug::from_trusted( | |
| 79 | + | format!("{}-{}", base_slug, suffix), | |
| 80 | + | ); | |
| 81 | + | continue; | |
| 82 | + | } | |
| 83 | + | Err(e) => return Err(e.into()), | |
| 84 | + | } | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | Ok(item) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Check whether a slug already exists for a given project. | |
| 91 | + | pub(super) async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>( | |
| 92 | + | executor: E, | |
| 93 | + | project_id: ProjectId, | |
| 94 | + | slug: &super::validated_types::Slug, | |
| 95 | + | ) -> Result<bool> { | |
| 96 | + | let exists: bool = sqlx::query_scalar( | |
| 97 | + | "SELECT EXISTS(SELECT 1 FROM items WHERE project_id = $1 AND slug = $2)", | |
| 98 | + | ) | |
| 99 | + | .bind(project_id) | |
| 100 | + | .bind(slug) | |
| 101 | + | .fetch_one(executor) | |
| 102 | + | .await?; | |
| 103 | + | ||
| 104 | + | Ok(exists) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Fetch an item by primary key. Returns `None` if not found. | |
| 108 | + | #[tracing::instrument(skip_all)] | |
| 109 | + | pub async fn get_item_by_id(pool: &PgPool, id: ItemId) -> Result<Option<DbItem>> { | |
| 110 | + | let item = sqlx::query_as::<_, DbItem>("SELECT * FROM items WHERE id = $1") | |
| 111 | + | .bind(id) | |
| 112 | + | .fetch_optional(pool) | |
| 113 | + | .await?; | |
| 114 | + | ||
| 115 | + | Ok(item) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Fetch titles for a batch of item IDs. Returns (item_id, title) pairs. | |
| 119 | + | #[tracing::instrument(skip_all)] | |
| 120 | + | pub async fn get_item_titles_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, String)>> { | |
| 121 | + | if ids.is_empty() { | |
| 122 | + | return Ok(vec![]); | |
| 123 | + | } | |
| 124 | + | let rows: Vec<(ItemId, String)> = sqlx::query_as( | |
| 125 | + | "SELECT id, title FROM items WHERE id = ANY($1)", | |
| 126 | + | ) | |
| 127 | + | .bind(ids) | |
| 128 | + | .fetch_all(pool) | |
| 129 | + | .await?; | |
| 130 | + | ||
| 131 | + | Ok(rows) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// Fetch project_id for a batch of item IDs. Returns (item_id, project_id) pairs. | |
| 135 | + | /// | |
| 136 | + | /// Used by bulk operations to verify all items belong to the same project in one query. | |
| 137 | + | #[tracing::instrument(skip_all)] | |
| 138 | + | pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, super::ProjectId)>> { | |
| 139 | + | if ids.is_empty() { | |
| 140 | + | return Ok(vec![]); | |
| 141 | + | } | |
| 142 | + | let rows: Vec<(ItemId, super::ProjectId)> = sqlx::query_as( | |
| 143 | + | "SELECT id, project_id FROM items WHERE id = ANY($1)", | |
| 144 | + | ) | |
| 145 | + | .bind(ids) | |
| 146 | + | .fetch_all(pool) | |
| 147 | + | .await?; | |
| 148 | + | ||
| 149 | + | Ok(rows) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// List all items in a project, ordered by sort_order then newest. | |
| 153 | + | /// | |
| 154 | + | /// Capped at 500 as a safety limit. | |
| 155 | + | #[tracing::instrument(skip_all)] | |
| 156 | + | pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> { | |
| 157 | + | let items = sqlx::query_as::<_, DbItem>( | |
| 158 | + | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 159 | + | ) | |
| 160 | + | .bind(project_id) | |
| 161 | + | .fetch_all(pool) | |
| 162 | + | .await?; | |
| 163 | + | ||
| 164 | + | Ok(items) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// List all items across all projects owned by a user, newest first. | |
| 168 | + | /// | |
| 169 | + | /// Capped at 500 as a safety limit. | |
| 170 | + | #[tracing::instrument(skip_all)] | |
| 171 | + | pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbItem>> { | |
| 172 | + | let items = sqlx::query_as::<_, DbItem>( | |
| 173 | + | r#" | |
| 174 | + | SELECT i.* FROM items i | |
| 175 | + | JOIN projects p ON i.project_id = p.id | |
| 176 | + | WHERE p.user_id = $1 AND i.deleted_at IS NULL | |
| 177 | + | ORDER BY i.created_at DESC | |
| 178 | + | LIMIT 500 | |
| 179 | + | "#, | |
| 180 | + | ) | |
| 181 | + | .bind(user_id) | |
| 182 | + | .fetch_all(pool) | |
| 183 | + | .await?; | |
| 184 | + | ||
| 185 | + | Ok(items) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// Count items per project for all projects owned by a user. | |
| 189 | + | /// | |
| 190 | + | /// Returns `(project_id, count)` tuples. Used by the CLI to avoid N+1 queries. | |
| 191 | + | #[tracing::instrument(skip_all)] | |
| 192 | + | pub async fn count_items_by_user_projects( | |
| 193 | + | pool: &PgPool, | |
| 194 | + | user_id: UserId, | |
| 195 | + | ) -> Result<Vec<(ProjectId, i64)>> { | |
| 196 | + | let rows: Vec<(ProjectId, i64)> = sqlx::query_as( | |
| 197 | + | r#" | |
| 198 | + | SELECT i.project_id, COUNT(*) AS cnt | |
| 199 | + | FROM items i | |
| 200 | + | JOIN projects p ON i.project_id = p.id | |
| 201 | + | WHERE p.user_id = $1 AND i.deleted_at IS NULL | |
| 202 | + | GROUP BY i.project_id | |
| 203 | + | "#, | |
| 204 | + | ) | |
| 205 | + | .bind(user_id) | |
| 206 | + | .fetch_all(pool) | |
| 207 | + | .await?; | |
| 208 | + | ||
| 209 | + | Ok(rows) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// List only public items in a project, ordered by sort_order then newest. | |
| 213 | + | /// | |
| 214 | + | /// Capped at 500 as a safety limit. | |
| 215 | + | #[tracing::instrument(skip_all)] | |
| 216 | + | pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> { | |
| 217 | + | let items = sqlx::query_as::<_, DbItem>( | |
| 218 | + | "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC LIMIT 500", | |
| 219 | + | ) | |
| 220 | + | .bind(project_id) | |
| 221 | + | .fetch_all(pool) | |
| 222 | + | .await?; | |
| 223 | + | ||
| 224 | + | Ok(items) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Partially update an item's fields (COALESCE keeps existing values when `None`). | |
| 228 | + | /// | |
| 229 | + | /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule, | |
| 230 | + | /// `Some(Some(dt))` = set schedule. | |
| 231 | + | #[allow(clippy::too_many_arguments)] | |
| 232 | + | #[tracing::instrument(skip_all)] | |
| 233 | + | pub async fn update_item( | |
| 234 | + | pool: &PgPool, | |
| 235 | + | id: ItemId, | |
| 236 | + | user_id: UserId, | |
| 237 | + | title: Option<&str>, | |
| 238 | + | description: Option<&str>, | |
| 239 | + | price_cents: Option<PriceCents>, | |
| 240 | + | item_type: Option<ItemType>, | |
| 241 | + | is_public: Option<bool>, | |
| 242 | + | pwyw_enabled: Option<bool>, | |
| 243 | + | pwyw_min_cents: Option<PriceCents>, | |
| 244 | + | publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>, | |
| 245 | + | web_only: Option<bool>, | |
| 246 | + | ai_tier: Option<AiTier>, | |
| 247 | + | ai_disclosure: Option<Option<&str>>, | |
| 248 | + | ) -> Result<DbItem> { | |
| 249 | + | // Flatten the double-Option: if outer is None, pass current DB value (via SQL CASE). | |
| 250 | + | // $10 = whether to update publish_at, $11 = the new value (NULL to clear). | |
| 251 | + | let update_publish_at = publish_at.is_some(); | |
| 252 | + | let publish_at_value = publish_at.flatten(); | |
| 253 | + | ||
| 254 | + | // ai_disclosure uses the same double-Option pattern as publish_at: | |
| 255 | + | // None = no change, Some(None) = clear, Some(Some(text)) = set. | |
| 256 | + | let update_ai_disclosure = ai_disclosure.is_some(); | |
| 257 | + | let ai_disclosure_value = ai_disclosure.flatten(); | |
| 258 | + | ||
| 259 | + | let item = sqlx::query_as::<_, DbItem>( | |
| 260 | + | r#" | |
| 261 | + | UPDATE items | |
| 262 | + | SET title = COALESCE($3, title), | |
| 263 | + | description = COALESCE($4, description), | |
| 264 | + | price_cents = COALESCE($5, price_cents), | |
| 265 | + | item_type = COALESCE($6, item_type), | |
| 266 | + | is_public = CASE WHEN removed_by_admin AND $7 = true THEN false ELSE COALESCE($7, is_public) END, | |
| 267 | + | pwyw_enabled = COALESCE($8, pwyw_enabled), | |
| 268 | + | pwyw_min_cents = COALESCE($9, pwyw_min_cents), | |
| 269 | + | publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END, | |
| 270 | + | web_only = COALESCE($12, web_only), | |
| 271 | + | ai_tier = COALESCE($13, ai_tier), | |
| 272 | + | ai_disclosure = CASE WHEN $14 THEN $15 ELSE ai_disclosure END | |
| 273 | + | WHERE id = $1 | |
| 274 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 275 | + | RETURNING * | |
| 276 | + | "#, | |
| 277 | + | ) | |
| 278 | + | .bind(id) | |
| 279 | + | .bind(user_id) | |
| 280 | + | .bind(title) | |
| 281 | + | .bind(description) | |
| 282 | + | .bind(price_cents) | |
| 283 | + | .bind(item_type) | |
| 284 | + | .bind(is_public) | |
| 285 | + | .bind(pwyw_enabled) | |
| 286 | + | .bind(pwyw_min_cents) | |
| 287 | + | .bind(update_publish_at) | |
| 288 | + | .bind(publish_at_value) | |
| 289 | + | .bind(web_only) | |
| 290 | + | .bind(ai_tier) | |
| 291 | + | .bind(update_ai_disclosure) | |
| 292 | + | .bind(ai_disclosure_value) | |
| 293 | + | .fetch_one(pool) | |
| 294 | + | .await?; | |
| 295 | + | ||
| 296 | + | Ok(item) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Publish all items whose scheduled publish time has passed. | |
| 300 | + | /// | |
| 301 | + | /// Atomically sets `is_public = true` and clears `publish_at`, returning the | |
| 302 | + | /// newly published items so the caller can send release announcements. | |
| 303 | + | #[tracing::instrument(skip_all)] | |
| 304 | + | pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> { | |
| 305 | + | let items = sqlx::query_as::<_, DbItem>( | |
| 306 | + | r#" | |
| 307 | + | UPDATE items | |
| 308 | + | SET is_public = true, publish_at = NULL, updated_at = NOW() | |
| 309 | + | WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false AND removed_by_admin = false | |
| 310 | + | RETURNING * | |
| 311 | + | "#, | |
| 312 | + | ) | |
| 313 | + | .fetch_all(pool) | |
| 314 | + | .await?; | |
| 315 | + | ||
| 316 | + | Ok(items) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /// Soft-delete an item (sets deleted_at, recoverable for 7 days). | |
| 320 | + | #[tracing::instrument(skip_all)] | |
| 321 | + | pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> { | |
| 322 | + | sqlx::query( | |
| 323 | + | r#" | |
| 324 | + | UPDATE items SET deleted_at = NOW(), is_public = false | |
| 325 | + | WHERE id = $1 AND deleted_at IS NULL | |
| 326 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 327 | + | "#, | |
| 328 | + | ) | |
| 329 | + | .bind(id) | |
| 330 | + | .bind(user_id) | |
| 331 | + | .execute(pool) | |
| 332 | + | .await?; | |
| 333 | + | ||
| 334 | + | Ok(()) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | /// Restore a soft-deleted item. | |
| 338 | + | #[tracing::instrument(skip_all)] | |
| 339 | + | pub async fn restore_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<bool> { | |
| 340 | + | let result = sqlx::query( | |
| 341 | + | r#" | |
| 342 | + | UPDATE items SET deleted_at = NULL | |
| 343 | + | WHERE id = $1 AND deleted_at IS NOT NULL | |
| 344 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 345 | + | "#, | |
| 346 | + | ) | |
| 347 | + | .bind(id) | |
| 348 | + | .bind(user_id) | |
| 349 | + | .execute(pool) | |
| 350 | + | .await?; | |
| 351 | + | ||
| 352 | + | Ok(result.rows_affected() > 0) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// Get soft-deleted items for a project (for the "Recently Deleted" section). | |
| 356 | + | #[tracing::instrument(skip_all)] | |
| 357 | + | pub async fn get_deleted_items_by_project( | |
| 358 | + | pool: &PgPool, | |
| 359 | + | project_id: ProjectId, | |
| 360 | + | ) -> Result<Vec<DbItem>> { | |
| 361 | + | let items = sqlx::query_as::<_, DbItem>( | |
| 362 | + | "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 500", | |
| 363 | + | ) | |
| 364 | + | .bind(project_id) | |
| 365 | + | .fetch_all(pool) | |
| 366 | + | .await?; | |
| 367 | + | ||
| 368 | + | Ok(items) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// Collect S3 keys from items that are about to be purged (soft-deleted >7 days). | |
| 372 | + | /// Returns all non-null S3 keys (audio, cover, video) so they can be deleted | |
| 373 | + | /// from S3 before the DB rows are removed. | |
| 374 | + | #[tracing::instrument(skip_all)] | |
| 375 | + | pub async fn get_expired_deleted_item_s3_keys(pool: &PgPool) -> Result<Vec<String>> { | |
| 376 | + | let keys: Vec<String> = sqlx::query_scalar( | |
| 377 | + | r#" | |
| 378 | + | SELECT k FROM ( | |
| 379 | + | SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k | |
| 380 | + | FROM items | |
| 381 | + | WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days' | |
| 382 | + | AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL) | |
| 383 | + | ) sub | |
| 384 | + | "#, | |
| 385 | + | ) | |
| 386 | + | .fetch_all(pool) | |
| 387 | + | .await?; | |
| 388 | + | ||
| 389 | + | Ok(keys) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | /// Collect S3 keys from versions belonging to items about to be purged. | |
| 393 | + | /// Must be called before purge since CASCADE delete destroys version rows. | |
| 394 | + | #[tracing::instrument(skip_all)] | |
| 395 | + | pub async fn get_expired_deleted_item_version_s3_keys(pool: &PgPool) -> Result<Vec<String>> { | |
| 396 | + | let keys: Vec<String> = sqlx::query_scalar( | |
| 397 | + | r#" | |
| 398 | + | SELECT v.s3_key | |
| 399 | + | FROM versions v | |
| 400 | + | JOIN items i ON v.item_id = i.id | |
| 401 | + | WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days' | |
| 402 | + | AND v.s3_key IS NOT NULL | |
| 403 | + | "#, | |
| 404 | + | ) | |
| 405 | + | .fetch_all(pool) | |
| 406 | + | .await?; | |
| 407 | + | ||
| 408 | + | Ok(keys) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | /// Sum total file sizes per user for items about to be purged, including version files. | |
| 412 | + | /// Returns (user_id, total_bytes) pairs for storage decrement. | |
| 413 | + | #[tracing::instrument(skip_all)] | |
| 414 | + | pub async fn get_expired_deleted_item_storage_by_user(pool: &PgPool) -> Result<Vec<(super::UserId, i64)>> { | |
| 415 | + | let rows: Vec<(super::UserId, i64)> = sqlx::query_as( | |
| 416 | + | r#" | |
| 417 | + | SELECT p.user_id, | |
| 418 | + | COALESCE(SUM( | |
| 419 | + | COALESCE(i.audio_file_size_bytes, 0) + | |
| 420 | + | COALESCE(i.cover_file_size_bytes, 0) + | |
| 421 | + | COALESCE(i.video_file_size_bytes, 0) + | |
| 422 | + | COALESCE(ver.version_bytes, 0) | |
| 423 | + | ), 0)::BIGINT AS total_bytes | |
| 424 | + | FROM items i | |
| 425 | + | JOIN projects p ON i.project_id = p.id | |
| 426 | + | LEFT JOIN LATERAL ( | |
| 427 | + | SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes | |
| 428 | + | FROM versions v | |
| 429 | + | WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL | |
| 430 | + | ) ver ON true | |
| 431 | + | WHERE i.deleted_at IS NOT NULL AND i.deleted_at < NOW() - INTERVAL '7 days' | |
| 432 | + | GROUP BY p.user_id | |
| 433 | + | "#, | |
| 434 | + | ) | |
| 435 | + | .fetch_all(pool) | |
| 436 | + | .await?; | |
| 437 | + | ||
| 438 | + | Ok(rows) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | /// Collect all S3 keys from items belonging to a project (audio, cover, video). | |
| 442 | + | #[tracing::instrument(skip_all)] | |
| 443 | + | pub async fn get_project_item_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> { | |
| 444 | + | let keys: Vec<String> = sqlx::query_scalar( | |
| 445 | + | r#" | |
| 446 | + | SELECT k FROM ( | |
| 447 | + | SELECT unnest(ARRAY_REMOVE(ARRAY[audio_s3_key, cover_s3_key, video_s3_key], NULL)) AS k | |
| 448 | + | FROM items | |
| 449 | + | WHERE project_id = $1 | |
| 450 | + | AND (audio_s3_key IS NOT NULL OR cover_s3_key IS NOT NULL OR video_s3_key IS NOT NULL) | |
| 451 | + | ) sub | |
| 452 | + | "#, | |
| 453 | + | ) | |
| 454 | + | .bind(project_id) | |
| 455 | + | .fetch_all(pool) | |
| 456 | + | .await?; | |
| 457 | + | ||
| 458 | + | Ok(keys) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | /// Collect S3 keys from versions belonging to items in a project. | |
| 462 | + | #[tracing::instrument(skip_all)] | |
| 463 | + | pub async fn get_project_version_s3_keys(pool: &PgPool, project_id: super::ProjectId) -> Result<Vec<String>> { | |
| 464 | + | let keys: Vec<String> = sqlx::query_scalar( | |
| 465 | + | r#" | |
| 466 | + | SELECT v.s3_key | |
| 467 | + | FROM versions v | |
| 468 | + | JOIN items i ON v.item_id = i.id | |
| 469 | + | WHERE i.project_id = $1 AND v.s3_key IS NOT NULL | |
| 470 | + | "#, | |
| 471 | + | ) | |
| 472 | + | .bind(project_id) | |
| 473 | + | .fetch_all(pool) | |
| 474 | + | .await?; | |
| 475 | + | ||
| 476 | + | Ok(keys) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | /// Sum total file sizes for all items and versions in a project. | |
| 480 | + | #[tracing::instrument(skip_all)] | |
| 481 | + | pub async fn get_project_storage_bytes(pool: &PgPool, project_id: super::ProjectId) -> Result<i64> { | |
| 482 | + | let total: i64 = sqlx::query_scalar( | |
| 483 | + | r#" | |
| 484 | + | SELECT COALESCE(SUM( | |
| 485 | + | COALESCE(i.audio_file_size_bytes, 0) + | |
| 486 | + | COALESCE(i.cover_file_size_bytes, 0) + | |
| 487 | + | COALESCE(i.video_file_size_bytes, 0) + | |
| 488 | + | COALESCE(ver.version_bytes, 0) | |
| 489 | + | ), 0)::BIGINT | |
| 490 | + | FROM items i | |
| 491 | + | LEFT JOIN LATERAL ( | |
| 492 | + | SELECT COALESCE(SUM(v.file_size_bytes), 0)::BIGINT AS version_bytes | |
| 493 | + | FROM versions v | |
| 494 | + | WHERE v.item_id = i.id AND v.file_size_bytes IS NOT NULL | |
| 495 | + | ) ver ON true | |
| 496 | + | WHERE i.project_id = $1 | |
| 497 | + | "#, | |
| 498 | + | ) | |
| 499 | + | .bind(project_id) | |
| 500 | + | .fetch_one(pool) |
Lines truncated
| @@ -1,1067 +0,0 @@ | |||
| 1 | - | //! SyncKit: device registration, change log, and encryption key management. | |
| 2 | - | ||
| 3 | - | use serde_json::Value as JsonValue; | |
| 4 | - | use sqlx::PgPool; | |
| 5 | - | ||
| 6 | - | use super::enums::SyncPlatform; | |
| 7 | - | use super::models::*; | |
| 8 | - | use super::{ItemId, ProjectId, SyncAppId, SyncDeviceId, UserId}; | |
| 9 | - | use crate::error::Result; | |
| 10 | - | ||
| 11 | - | // ── Sync Apps ── | |
| 12 | - | ||
| 13 | - | /// Compute the SHA-256 hash of an API key (hex-encoded). | |
| 14 | - | pub fn hash_api_key(api_key: &str) -> String { | |
| 15 | - | use sha2::Digest; | |
| 16 | - | let hash = sha2::Sha256::digest(api_key.as_bytes()); | |
| 17 | - | hex::encode(hash) | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | /// Create a new sync app. Stores the hashed API key and prefix. | |
| 21 | - | #[tracing::instrument(skip_all)] | |
| 22 | - | pub async fn create_sync_app( | |
| 23 | - | pool: &PgPool, | |
| 24 | - | creator_id: UserId, | |
| 25 | - | name: &str, | |
| 26 | - | api_key: &str, | |
| 27 | - | project_id: Option<ProjectId>, | |
| 28 | - | item_id: Option<ItemId>, | |
| 29 | - | ) -> Result<DbSyncApp> { | |
| 30 | - | let key_hash = hash_api_key(api_key); | |
| 31 | - | let key_prefix = &api_key[..8.min(api_key.len())]; | |
| 32 | - | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 33 | - | r#" | |
| 34 | - | INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id) | |
| 35 | - | VALUES ($1, $2, $3, $4, $5, $6) | |
| 36 | - | RETURNING * | |
| 37 | - | "#, | |
| 38 | - | ) | |
| 39 | - | .bind(creator_id) | |
| 40 | - | .bind(name) | |
| 41 | - | .bind(&key_hash) | |
| 42 | - | .bind(key_prefix) | |
| 43 | - | .bind(project_id) | |
| 44 | - | .bind(item_id) | |
| 45 | - | .fetch_one(pool) | |
| 46 | - | .await?; | |
| 47 | - | ||
| 48 | - | Ok(app) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | /// Update the project/item link for a sync app. | |
| 52 | - | #[tracing::instrument(skip_all)] | |
| 53 | - | pub async fn update_sync_app_link( | |
| 54 | - | pool: &PgPool, | |
| 55 | - | app_id: SyncAppId, | |
| 56 | - | project_id: Option<ProjectId>, | |
| 57 | - | item_id: Option<ItemId>, | |
| 58 | - | ) -> Result<DbSyncApp> { | |
| 59 | - | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 60 | - | r#" | |
| 61 | - | UPDATE sync_apps SET project_id = $2, item_id = $3 | |
| 62 | - | WHERE id = $1 | |
| 63 | - | RETURNING * | |
| 64 | - | "#, | |
| 65 | - | ) | |
| 66 | - | .bind(app_id) | |
| 67 | - | .bind(project_id) | |
| 68 | - | .bind(item_id) | |
| 69 | - | .fetch_one(pool) | |
| 70 | - | .await?; | |
| 71 | - | ||
| 72 | - | Ok(app) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | /// Get a sync app by API key (only if active). Hashes the input before lookup. | |
| 76 | - | #[tracing::instrument(skip_all)] | |
| 77 | - | pub async fn get_sync_app_by_api_key( | |
| 78 | - | pool: &PgPool, | |
| 79 | - | api_key: &str, | |
| 80 | - | ) -> Result<Option<DbSyncApp>> { | |
| 81 | - | let key_hash = hash_api_key(api_key); | |
| 82 | - | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 83 | - | "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true", | |
| 84 | - | ) | |
| 85 | - | .bind(&key_hash) | |
| 86 | - | .fetch_optional(pool) | |
| 87 | - | .await?; | |
| 88 | - | ||
| 89 | - | Ok(app) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | /// Get a sync app by ID. | |
| 93 | - | #[tracing::instrument(skip_all)] | |
| 94 | - | pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result<Option<DbSyncApp>> { | |
| 95 | - | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 96 | - | "SELECT * FROM sync_apps WHERE id = $1", | |
| 97 | - | ) | |
| 98 | - | .bind(id) | |
| 99 | - | .fetch_optional(pool) | |
| 100 | - | .await?; | |
| 101 | - | ||
| 102 | - | Ok(app) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | /// List all sync apps for a creator. | |
| 106 | - | #[tracing::instrument(skip_all)] | |
| 107 | - | pub async fn get_sync_apps_by_creator( | |
| 108 | - | pool: &PgPool, | |
| 109 | - | creator_id: UserId, | |
| 110 | - | ) -> Result<Vec<DbSyncApp>> { | |
| 111 | - | let apps = sqlx::query_as::<_, DbSyncApp>( | |
| 112 | - | "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100", | |
| 113 | - | ) | |
| 114 | - | .bind(creator_id) | |
| 115 | - | .fetch_all(pool) | |
| 116 | - | .await?; | |
| 117 | - | ||
| 118 | - | Ok(apps) | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | /// Get all sync apps linked to a specific project. | |
| 122 | - | pub async fn get_sync_apps_by_project( | |
| 123 | - | pool: &PgPool, | |
| 124 | - | project_id: ProjectId, | |
| 125 | - | ) -> Result<Vec<DbSyncApp>> { | |
| 126 | - | let apps = sqlx::query_as::<_, DbSyncApp>( | |
| 127 | - | "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100", | |
| 128 | - | ) | |
| 129 | - | .bind(project_id) | |
| 130 | - | .fetch_all(pool) | |
| 131 | - | .await?; | |
| 132 | - | ||
| 133 | - | Ok(apps) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | /// Regenerate an API key for a sync app. Stores the hashed key and prefix. | |
| 137 | - | #[tracing::instrument(skip_all)] | |
| 138 | - | pub async fn regenerate_sync_app_key( | |
| 139 | - | pool: &PgPool, | |
| 140 | - | app_id: SyncAppId, | |
| 141 | - | new_api_key: &str, | |
| 142 | - | ) -> Result<DbSyncApp> { | |
| 143 | - | let key_hash = hash_api_key(new_api_key); | |
| 144 | - | let key_prefix = &new_api_key[..8.min(new_api_key.len())]; | |
| 145 | - | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 146 | - | r#" | |
| 147 | - | UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3 | |
| 148 | - | WHERE id = $1 | |
| 149 | - | RETURNING * | |
| 150 | - | "#, | |
| 151 | - | ) | |
| 152 | - | .bind(app_id) | |
| 153 | - | .bind(&key_hash) | |
| 154 | - | .bind(key_prefix) | |
| 155 | - | .fetch_one(pool) | |
| 156 | - | .await?; | |
| 157 | - | ||
| 158 | - | Ok(app) | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | /// Delete a sync app (cascades to devices and log entries). | |
| 162 | - | #[tracing::instrument(skip_all)] | |
| 163 | - | pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> { | |
| 164 | - | sqlx::query("DELETE FROM sync_apps WHERE id = $1") | |
| 165 | - | .bind(app_id) | |
| 166 | - | .execute(pool) | |
| 167 | - | .await?; | |
| 168 | - | ||
| 169 | - | Ok(()) | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | // ── Sync Devices ── | |
| 173 | - | ||
| 174 | - | /// Register or update a sync device. | |
| 175 | - | /// | |
| 176 | - | /// Upserts on the `(app_id, user_id, device_name)` unique constraint. | |
| 177 | - | /// On conflict (same device re-registering), updates the platform string | |
| 178 | - | /// and bumps `last_seen_at` so stale-device detection stays accurate. | |
| 179 | - | #[tracing::instrument(skip_all)] | |
| 180 | - | pub async fn upsert_sync_device( | |
| 181 | - | pool: &PgPool, | |
| 182 | - | app_id: SyncAppId, | |
| 183 | - | user_id: UserId, | |
| 184 | - | device_name: &str, | |
| 185 | - | platform: SyncPlatform, | |
| 186 | - | ) -> Result<DbSyncDevice> { | |
| 187 | - | let device = sqlx::query_as::<_, DbSyncDevice>( | |
| 188 | - | r#" | |
| 189 | - | INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 190 | - | VALUES ($1, $2, $3, $4) | |
| 191 | - | ON CONFLICT (app_id, user_id, device_name) | |
| 192 | - | DO UPDATE SET platform = EXCLUDED.platform, last_seen_at = NOW() | |
| 193 | - | RETURNING * | |
| 194 | - | "#, | |
| 195 | - | ) | |
| 196 | - | .bind(app_id) | |
| 197 | - | .bind(user_id) | |
| 198 | - | .bind(device_name) | |
| 199 | - | .bind(platform) | |
| 200 | - | .fetch_one(pool) | |
| 201 | - | .await?; | |
| 202 | - | ||
| 203 | - | Ok(device) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | /// List all devices for a user within an app. | |
| 207 | - | #[tracing::instrument(skip_all)] | |
| 208 | - | pub async fn get_sync_devices( | |
| 209 | - | pool: &PgPool, | |
| 210 | - | app_id: SyncAppId, | |
| 211 | - | user_id: UserId, | |
| 212 | - | ) -> Result<Vec<DbSyncDevice>> { | |
| 213 | - | let devices = sqlx::query_as::<_, DbSyncDevice>( | |
| 214 | - | "SELECT * FROM sync_devices WHERE app_id = $1 AND user_id = $2 ORDER BY last_seen_at DESC LIMIT 100", | |
| 215 | - | ) | |
| 216 | - | .bind(app_id) | |
| 217 | - | .bind(user_id) | |
| 218 | - | .fetch_all(pool) | |
| 219 | - | .await?; | |
| 220 | - | ||
| 221 | - | Ok(devices) | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | /// Delete a device by ID (only if owned by user within app). | |
| 225 | - | #[tracing::instrument(skip_all)] | |
| 226 | - | pub async fn delete_sync_device( | |
| 227 | - | pool: &PgPool, | |
| 228 | - | device_id: SyncDeviceId, | |
| 229 | - | app_id: SyncAppId, | |
| 230 | - | user_id: UserId, | |
| 231 | - | ) -> Result<bool> { | |
| 232 | - | let result = sqlx::query( | |
| 233 | - | "DELETE FROM sync_devices WHERE id = $1 AND app_id = $2 AND user_id = $3", | |
| 234 | - | ) | |
| 235 | - | .bind(device_id) | |
| 236 | - | .bind(app_id) | |
| 237 | - | .bind(user_id) | |
| 238 | - | .execute(pool) | |
| 239 | - | .await?; | |
| 240 | - | ||
| 241 | - | Ok(result.rows_affected() > 0) | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | /// Touch last_seen_at for a device. | |
| 245 | - | #[tracing::instrument(skip_all)] | |
| 246 | - | pub async fn touch_sync_device(pool: &PgPool, device_id: SyncDeviceId) -> Result<()> { | |
| 247 | - | sqlx::query("UPDATE sync_devices SET last_seen_at = NOW() WHERE id = $1") | |
| 248 | - | .bind(device_id) | |
| 249 | - | .execute(pool) | |
| 250 | - | .await?; | |
| 251 | - | ||
| 252 | - | Ok(()) | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | // ── Sync Log ── | |
| 256 | - | ||
| 257 | - | /// Push a batch of changes to the sync log. Returns the highest seq assigned. | |
| 258 | - | /// | |
| 259 | - | /// `batch_id` is a client-generated UUID for idempotent push. If a batch with | |
| 260 | - | /// the same ID has already been committed for this app+user, the existing max | |
| 261 | - | /// seq is returned without re-inserting (at-most-once semantics). | |
| 262 | - | /// | |
| 263 | - | /// All changes are inserted within a single transaction for atomicity and | |
| 264 | - | /// performance (one fsync instead of N). | |
| 265 | - | #[allow(clippy::type_complexity)] | |
| 266 | - | #[tracing::instrument(skip_all)] | |
| 267 | - | pub async fn push_sync_changes( | |
| 268 | - | pool: &PgPool, | |
| 269 | - | app_id: SyncAppId, | |
| 270 | - | user_id: UserId, | |
| 271 | - | device_id: SyncDeviceId, | |
| 272 | - | batch_id: uuid::Uuid, | |
| 273 | - | changes: &[(String, String, String, chrono::DateTime<chrono::Utc>, Option<JsonValue>)], | |
| 274 | - | ) -> Result<i64> { | |
| 275 | - | if changes.is_empty() { | |
| 276 | - | return Ok(0); | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | // Decompose into parallel Vecs for UNNEST-based batch INSERT | |
| 280 | - | let mut table_names: Vec<String> = Vec::with_capacity(changes.len()); | |
| 281 | - | let mut operations: Vec<String> = Vec::with_capacity(changes.len()); | |
| 282 | - | let mut row_ids: Vec<String> = Vec::with_capacity(changes.len()); | |
| 283 | - | let mut client_timestamps: Vec<chrono::DateTime<chrono::Utc>> = Vec::with_capacity(changes.len()); | |
| 284 | - | let mut data_values: Vec<JsonValue> = Vec::with_capacity(changes.len()); | |
| 285 | - | ||
| 286 | - | for (table_name, operation, row_id, client_timestamp, data) in changes { | |
| 287 | - | table_names.push(table_name.clone()); | |
| 288 | - | operations.push(operation.clone()); | |
| 289 | - | row_ids.push(row_id.clone()); | |
| 290 | - | client_timestamps.push(*client_timestamp); | |
| 291 | - | data_values.push(data.clone().unwrap_or(JsonValue::Null)); | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | let mut tx = pool.begin().await?; | |
| 295 | - | ||
| 296 | - | // Idempotency check inside the transaction to prevent TOCTOU race. | |
| 297 | - | let existing: (Option<i64>,) = sqlx::query_as( | |
| 298 | - | "SELECT MAX(seq) FROM sync_log WHERE app_id = $1 AND user_id = $2 AND batch_id = $3", | |
| 299 | - | ) | |
| 300 | - | .bind(app_id) | |
| 301 | - | .bind(user_id) | |
| 302 | - | .bind(batch_id) | |
| 303 | - | .fetch_one(&mut *tx) | |
| 304 | - | .await?; | |
| 305 | - | ||
| 306 | - | if let Some(max_seq) = existing.0 { | |
| 307 | - | tracing::debug!(batch_id = %batch_id, cursor = max_seq, "Push batch already committed, returning existing cursor"); | |
| 308 | - | tx.rollback().await.ok(); | |
| 309 | - | return Ok(max_seq); | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | // Read the current key_id from sync_keys so pushed entries are stamped | |
| 313 | - | // with the active encryption key. Falls back to NULL if no key exists yet | |
| 314 | - | // (pre-encryption setup — entries have no encrypted data anyway). | |
| 315 | - | let current_key_id: Option<i32> = sqlx::query_scalar( | |
| 316 | - | "SELECT key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2", | |
| 317 | - | ) | |
| 318 | - | .bind(app_id) | |
| 319 | - | .bind(user_id) | |
| 320 | - | .fetch_optional(&mut *tx) | |
| 321 | - | .await?; | |
| 322 | - | ||
| 323 | - | let seqs: Vec<i64> = sqlx::query_scalar( | |
| 324 | - | r#" | |
| 325 | - | INSERT INTO sync_log (app_id, user_id, device_id, batch_id, table_name, operation, row_id, client_timestamp, data, key_id) | |
| 326 | - | SELECT $1, $2, $3, $4, t.*, $10 | |
| 327 | - | FROM UNNEST($5::text[], $6::text[], $7::text[], $8::timestamptz[], $9::jsonb[]) AS t | |
| 328 | - | RETURNING seq | |
| 329 | - | "#, | |
| 330 | - | ) | |
| 331 | - | .bind(app_id) | |
| 332 | - | .bind(user_id) | |
| 333 | - | .bind(device_id) | |
| 334 | - | .bind(batch_id) | |
| 335 | - | .bind(&table_names) | |
| 336 | - | .bind(&operations) | |
| 337 | - | .bind(&row_ids) | |
| 338 | - | .bind(&client_timestamps) | |
| 339 | - | .bind(&data_values) | |
| 340 | - | .bind(current_key_id) | |
| 341 | - | .fetch_all(&mut *tx) | |
| 342 | - | .await?; | |
| 343 | - | ||
| 344 | - | tx.commit().await?; | |
| 345 | - | ||
| 346 | - | let max_seq = seqs.iter().copied().max().unwrap_or(0); | |
| 347 | - | ||
| 348 | - | Ok(max_seq) | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | /// Pull changes since a cursor for a user within an app. | |
| 352 | - | /// | |
| 353 | - | /// Prefer `pull_sync_changes_filtered` for new code — it supports optional | |
| 354 | - | /// table and timestamp filters. This function is kept for backward compatibility. | |
| 355 | - | #[allow(dead_code)] | |
| 356 | - | #[tracing::instrument(skip_all)] | |
| 357 | - | pub async fn pull_sync_changes( | |
| 358 | - | pool: &PgPool, | |
| 359 | - | app_id: SyncAppId, | |
| 360 | - | user_id: UserId, | |
| 361 | - | cursor: i64, | |
| 362 | - | limit: i64, | |
| 363 | - | ) -> Result<Vec<DbSyncLogEntry>> { | |
| 364 | - | let entries = sqlx::query_as::<_, DbSyncLogEntry>( | |
| 365 | - | r#" | |
| 366 | - | SELECT * FROM sync_log | |
| 367 | - | WHERE app_id = $1 AND user_id = $2 AND seq > $3 | |
| 368 | - | ORDER BY seq ASC | |
| 369 | - | LIMIT $4 | |
| 370 | - | "#, | |
| 371 | - | ) | |
| 372 | - | .bind(app_id) | |
| 373 | - | .bind(user_id) | |
| 374 | - | .bind(cursor) | |
| 375 | - | .bind(limit) | |
| 376 | - | .fetch_all(pool) | |
| 377 | - | .await?; | |
| 378 | - | ||
| 379 | - | Ok(entries) | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | /// Pull changes since a cursor with optional table and timestamp filters. | |
| 383 | - | /// | |
| 384 | - | /// When `tables` is `Some`, only entries whose `table_name` is in the list are returned. | |
| 385 | - | /// When `since` is `Some`, only entries with `client_timestamp >= since` are returned. | |
| 386 | - | /// Both filters compose (AND). Passing `None` for both is identical to `pull_sync_changes`. | |
| 387 | - | #[tracing::instrument(skip_all)] | |
| 388 | - | pub async fn pull_sync_changes_filtered( | |
| 389 | - | pool: &PgPool, | |
| 390 | - | app_id: SyncAppId, | |
| 391 | - | user_id: UserId, | |
| 392 | - | cursor: i64, | |
| 393 | - | limit: i64, | |
| 394 | - | tables: Option<&[String]>, | |
| 395 | - | since: Option<chrono::DateTime<chrono::Utc>>, | |
| 396 | - | ) -> Result<Vec<DbSyncLogEntry>> { | |
| 397 | - | let entries = sqlx::query_as::<_, DbSyncLogEntry>( | |
| 398 | - | r#" | |
| 399 | - | SELECT * FROM sync_log | |
| 400 | - | WHERE app_id = $1 AND user_id = $2 AND seq > $3 | |
| 401 | - | AND ($5::text[] IS NULL OR table_name = ANY($5)) | |
| 402 | - | AND ($6::timestamptz IS NULL OR client_timestamp >= $6) | |
| 403 | - | ORDER BY seq ASC | |
| 404 | - | LIMIT $4 | |
| 405 | - | "#, | |
| 406 | - | ) | |
| 407 | - | .bind(app_id) | |
| 408 | - | .bind(user_id) | |
| 409 | - | .bind(cursor) | |
| 410 | - | .bind(limit) | |
| 411 | - | .bind(tables) | |
| 412 | - | .bind(since) | |
| 413 | - | .fetch_all(pool) | |
| 414 | - | .await?; | |
| 415 | - | ||
| 416 | - | Ok(entries) | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | // ── Sync Keys ── | |
| 420 | - | ||
| 421 | - | /// Upsert an encrypted master key for a user within an app with optimistic | |
| 422 | - | /// concurrency control. | |
| 423 | - | /// | |
| 424 | - | /// `expected_version` is checked on UPDATE: if the current `key_version` | |
| 425 | - | /// doesn't match, the update is skipped and `false` is returned (the caller | |
| 426 | - | /// should return 409 Conflict). On INSERT (first key), `expected_version` | |
| 427 | - | /// must be 0. | |
| 428 | - | /// | |
| 429 | - | /// Returns `true` if the key was inserted or updated, `false` on version mismatch. | |
| 430 | - | #[tracing::instrument(skip_all)] | |
| 431 | - | pub async fn upsert_sync_key( | |
| 432 | - | pool: &PgPool, | |
| 433 | - | app_id: SyncAppId, | |
| 434 | - | user_id: UserId, | |
| 435 | - | encrypted_key: &str, | |
| 436 | - | expected_version: i32, | |
| 437 | - | ) -> Result<bool> { | |
| 438 | - | let result = sqlx::query( | |
| 439 | - | r#" | |
| 440 | - | INSERT INTO sync_keys (app_id, user_id, encrypted_key) | |
| 441 | - | VALUES ($1, $2, $3) | |
| 442 | - | ON CONFLICT (app_id, user_id) | |
| 443 | - | DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, | |
| 444 | - | key_version = sync_keys.key_version + 1, | |
| 445 | - | updated_at = NOW() | |
| 446 | - | WHERE sync_keys.key_version = $4 | |
| 447 | - | "#, | |
| 448 | - | ) | |
| 449 | - | .bind(app_id) | |
| 450 | - | .bind(user_id) | |
| 451 | - | .bind(encrypted_key) | |
| 452 | - | .bind(expected_version) | |
| 453 | - | .execute(pool) | |
| 454 | - | .await?; | |
| 455 | - | ||
| 456 | - | Ok(result.rows_affected() > 0) | |
| 457 | - | } | |
| 458 | - | ||
| 459 | - | /// Encryption key info returned by `get_sync_key`. | |
| 460 | - | pub struct SyncKeyInfo { | |
| 461 | - | pub encrypted_key: String, | |
| 462 | - | pub key_version: i32, | |
| 463 | - | pub key_id: i32, | |
| 464 | - | /// If a key rotation is in progress, the new key envelope and its key_id. | |
| 465 | - | pub pending_key: Option<(String, i32)>, | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | /// Get the encrypted master key, version, key_id, and any pending rotation key. | |
| 469 | - | #[tracing::instrument(skip_all)] | |
| 470 | - | pub async fn get_sync_key( | |
| 471 | - | pool: &PgPool, | |
| 472 | - | app_id: SyncAppId, | |
| 473 | - | user_id: UserId, | |
| 474 | - | ) -> Result<Option<SyncKeyInfo>> { | |
| 475 | - | let row: Option<(String, i32, i32)> = sqlx::query_as( | |
| 476 | - | "SELECT encrypted_key, key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2", | |
| 477 | - | ) | |
| 478 | - | .bind(app_id) | |
| 479 | - | .bind(user_id) | |
| 480 | - | .fetch_optional(pool) | |
| 481 | - | .await?; | |
| 482 | - | ||
| 483 | - | let Some((encrypted_key, key_version, key_id)) = row else { | |
| 484 | - | return Ok(None); | |
| 485 | - | }; | |
| 486 | - | ||
| 487 | - | // Check for an active rotation | |
| 488 | - | let pending: Option<(String, i32)> = sqlx::query_as( | |
| 489 | - | "SELECT new_encrypted_key, new_key_id FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2", | |
| 490 | - | ) | |
| 491 | - | .bind(app_id) | |
| 492 | - | .bind(user_id) | |
| 493 | - | .fetch_optional(pool) | |
| 494 | - | .await?; | |
| 495 | - | ||
| 496 | - | Ok(Some(SyncKeyInfo { | |
| 497 | - | encrypted_key, | |
| 498 | - | key_version, | |
| 499 | - | key_id, | |
| 500 | - | pending_key: pending, |
Lines truncated
| @@ -0,0 +1,167 @@ | |||
| 1 | + | use sqlx::PgPool; | |
| 2 | + | ||
| 3 | + | use crate::db::models::*; | |
| 4 | + | use crate::db::{ItemId, ProjectId, SyncAppId, UserId}; | |
| 5 | + | use crate::error::Result; | |
| 6 | + | ||
| 7 | + | // ── Sync Apps ── | |
| 8 | + | ||
| 9 | + | /// Compute the SHA-256 hash of an API key (hex-encoded). | |
| 10 | + | pub fn hash_api_key(api_key: &str) -> String { | |
| 11 | + | use sha2::Digest; | |
| 12 | + | let hash = sha2::Sha256::digest(api_key.as_bytes()); | |
| 13 | + | hex::encode(hash) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// Create a new sync app. Stores the hashed API key and prefix. | |
| 17 | + | #[tracing::instrument(skip_all)] | |
| 18 | + | pub async fn create_sync_app( | |
| 19 | + | pool: &PgPool, | |
| 20 | + | creator_id: UserId, | |
| 21 | + | name: &str, | |
| 22 | + | api_key: &str, | |
| 23 | + | project_id: Option<ProjectId>, | |
| 24 | + | item_id: Option<ItemId>, | |
| 25 | + | ) -> Result<DbSyncApp> { | |
| 26 | + | let key_hash = hash_api_key(api_key); | |
| 27 | + | let key_prefix = &api_key[..8.min(api_key.len())]; | |
| 28 | + | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 29 | + | r#" | |
| 30 | + | INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id) | |
| 31 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 32 | + | RETURNING * | |
| 33 | + | "#, | |
| 34 | + | ) | |
| 35 | + | .bind(creator_id) | |
| 36 | + | .bind(name) | |
| 37 | + | .bind(&key_hash) | |
| 38 | + | .bind(key_prefix) | |
| 39 | + | .bind(project_id) | |
| 40 | + | .bind(item_id) | |
| 41 | + | .fetch_one(pool) | |
| 42 | + | .await?; | |
| 43 | + | ||
| 44 | + | Ok(app) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Update the project/item link for a sync app. | |
| 48 | + | #[tracing::instrument(skip_all)] | |
| 49 | + | pub async fn update_sync_app_link( | |
| 50 | + | pool: &PgPool, | |
| 51 | + | app_id: SyncAppId, | |
| 52 | + | project_id: Option<ProjectId>, | |
| 53 | + | item_id: Option<ItemId>, | |
| 54 | + | ) -> Result<DbSyncApp> { | |
| 55 | + | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 56 | + | r#" | |
| 57 | + | UPDATE sync_apps SET project_id = $2, item_id = $3 | |
| 58 | + | WHERE id = $1 | |
| 59 | + | RETURNING * | |
| 60 | + | "#, | |
| 61 | + | ) | |
| 62 | + | .bind(app_id) | |
| 63 | + | .bind(project_id) | |
| 64 | + | .bind(item_id) | |
| 65 | + | .fetch_one(pool) | |
| 66 | + | .await?; | |
| 67 | + | ||
| 68 | + | Ok(app) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Get a sync app by API key (only if active). Hashes the input before lookup. | |
| 72 | + | #[tracing::instrument(skip_all)] | |
| 73 | + | pub async fn get_sync_app_by_api_key( | |
| 74 | + | pool: &PgPool, | |
| 75 | + | api_key: &str, | |
| 76 | + | ) -> Result<Option<DbSyncApp>> { | |
| 77 | + | let key_hash = hash_api_key(api_key); | |
| 78 | + | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 79 | + | "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true", | |
| 80 | + | ) | |
| 81 | + | .bind(&key_hash) | |
| 82 | + | .fetch_optional(pool) | |
| 83 | + | .await?; | |
| 84 | + | ||
| 85 | + | Ok(app) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Get a sync app by ID. | |
| 89 | + | #[tracing::instrument(skip_all)] | |
| 90 | + | pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result<Option<DbSyncApp>> { | |
| 91 | + | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 92 | + | "SELECT * FROM sync_apps WHERE id = $1", | |
| 93 | + | ) | |
| 94 | + | .bind(id) | |
| 95 | + | .fetch_optional(pool) | |
| 96 | + | .await?; | |
| 97 | + | ||
| 98 | + | Ok(app) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// List all sync apps for a creator. | |
| 102 | + | #[tracing::instrument(skip_all)] | |
| 103 | + | pub async fn get_sync_apps_by_creator( | |
| 104 | + | pool: &PgPool, | |
| 105 | + | creator_id: UserId, | |
| 106 | + | ) -> Result<Vec<DbSyncApp>> { | |
| 107 | + | let apps = sqlx::query_as::<_, DbSyncApp>( | |
| 108 | + | "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100", | |
| 109 | + | ) | |
| 110 | + | .bind(creator_id) | |
| 111 | + | .fetch_all(pool) | |
| 112 | + | .await?; | |
| 113 | + | ||
| 114 | + | Ok(apps) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Get all sync apps linked to a specific project. | |
| 118 | + | pub async fn get_sync_apps_by_project( | |
| 119 | + | pool: &PgPool, | |
| 120 | + | project_id: ProjectId, | |
| 121 | + | ) -> Result<Vec<DbSyncApp>> { | |
| 122 | + | let apps = sqlx::query_as::<_, DbSyncApp>( | |
| 123 | + | "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100", | |
| 124 | + | ) | |
| 125 | + | .bind(project_id) | |
| 126 | + | .fetch_all(pool) | |
| 127 | + | .await?; | |
| 128 | + | ||
| 129 | + | Ok(apps) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Regenerate an API key for a sync app. Stores the hashed key and prefix. | |
| 133 | + | #[tracing::instrument(skip_all)] | |
| 134 | + | pub async fn regenerate_sync_app_key( | |
| 135 | + | pool: &PgPool, | |
| 136 | + | app_id: SyncAppId, | |
| 137 | + | new_api_key: &str, | |
| 138 | + | ) -> Result<DbSyncApp> { | |
| 139 | + | let key_hash = hash_api_key(new_api_key); | |
| 140 | + | let key_prefix = &new_api_key[..8.min(new_api_key.len())]; | |
| 141 | + | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 142 | + | r#" | |
| 143 | + | UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3 | |
| 144 | + | WHERE id = $1 | |
| 145 | + | RETURNING * | |
| 146 | + | "#, | |
| 147 | + | ) | |
| 148 | + | .bind(app_id) | |
| 149 | + | .bind(&key_hash) | |
| 150 | + | .bind(key_prefix) | |
| 151 | + | .fetch_one(pool) | |
| 152 | + | .await?; | |
| 153 | + | ||
| 154 | + | Ok(app) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Delete a sync app (cascades to devices and log entries). | |
| 158 | + | #[tracing::instrument(skip_all)] | |
| 159 | + | pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> { | |
| 160 | + | sqlx::query("DELETE FROM sync_apps WHERE id = $1") | |
| 161 | + | .bind(app_id) | |
| 162 | + | .execute(pool) | |
| 163 | + | .await?; | |
| 164 | + | ||
| 165 | + | Ok(()) | |
| 166 | + | } | |
| 167 | + |
| @@ -0,0 +1,96 @@ | |||
| 1 | + | use sqlx::PgPool; | |
| 2 | + | ||
| 3 | + | use crate::db::models::*; | |
| 4 | + | use crate::db::{SyncAppId, UserId}; | |
| 5 | + | use crate::error::Result; | |
| 6 | + | ||
| 7 | + | // ── Sync Blobs ── | |
| 8 | + | ||
| 9 | + | /// Get a blob by content hash for a user within an app. | |
| 10 | + | #[tracing::instrument(skip_all)] | |
| 11 | + | pub async fn get_sync_blob_by_hash( | |
| 12 | + | pool: &PgPool, | |
| 13 | + | app_id: SyncAppId, | |
| 14 | + | user_id: UserId, | |
| 15 | + | hash: &str, | |
| 16 | + | ) -> Result<Option<DbSyncBlob>> { | |
| 17 | + | let blob = sqlx::query_as::<_, DbSyncBlob>( | |
| 18 | + | "SELECT * FROM sync_blobs WHERE app_id = $1 AND user_id = $2 AND hash = $3", | |
| 19 | + | ) | |
| 20 | + | .bind(app_id) | |
| 21 | + | .bind(user_id) | |
| 22 | + | .bind(hash) | |
| 23 | + | .fetch_optional(pool) | |
| 24 | + | .await?; | |
| 25 | + | ||
| 26 | + | Ok(blob) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Insert a sync blob, updating size on conflict (idempotent). | |
| 30 | + | /// | |
| 31 | + | /// Uses `ON CONFLICT DO UPDATE` to keep `size_bytes` consistent with the | |
| 32 | + | /// actual S3 object when concurrent uploads race. | |
| 33 | + | #[tracing::instrument(skip_all)] | |
| 34 | + | pub async fn create_sync_blob_idempotent( | |
| 35 | + | pool: &PgPool, | |
| 36 | + | app_id: SyncAppId, | |
| 37 | + | user_id: UserId, | |
| 38 | + | hash: &str, | |
| 39 | + | size_bytes: i64, | |
| 40 | + | s3_key: &str, | |
| 41 | + | ) -> Result<()> { | |
| 42 | + | sqlx::query( | |
| 43 | + | r#" | |
| 44 | + | INSERT INTO sync_blobs (app_id, user_id, hash, size_bytes, s3_key) | |
| 45 | + | VALUES ($1, $2, $3, $4, $5) | |
| 46 | + | ON CONFLICT (app_id, user_id, hash) | |
| 47 | + | DO UPDATE SET size_bytes = EXCLUDED.size_bytes | |
| 48 | + | "#, | |
| 49 | + | ) | |
| 50 | + | .bind(app_id) | |
| 51 | + | .bind(user_id) | |
| 52 | + | .bind(hash) | |
| 53 | + | .bind(size_bytes) | |
| 54 | + | .bind(s3_key) | |
| 55 | + | .execute(pool) | |
| 56 | + | .await?; | |
| 57 | + | ||
| 58 | + | Ok(()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Get total blob storage used by a user for an app (in bytes). | |
| 62 | + | #[tracing::instrument(skip_all)] | |
| 63 | + | pub async fn get_blob_storage_used( | |
| 64 | + | pool: &PgPool, | |
| 65 | + | app_id: SyncAppId, | |
| 66 | + | user_id: UserId, | |
| 67 | + | ) -> Result<i64> { | |
| 68 | + | let total: Option<i64> = sqlx::query_scalar( | |
| 69 | + | "SELECT SUM(size_bytes)::BIGINT FROM sync_blobs WHERE app_id = $1 AND user_id = $2", | |
| 70 | + | ) | |
| 71 | + | .bind(app_id) | |
| 72 | + | .bind(user_id) | |
| 73 | + | .fetch_one(pool) | |
| 74 | + | .await?; | |
| 75 | + | ||
| 76 | + | Ok(total.unwrap_or(0)) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Count devices registered for a user/app pair. | |
| 80 | + | #[tracing::instrument(skip_all)] | |
| 81 | + | pub async fn count_sync_devices( | |
| 82 | + | pool: &PgPool, | |
| 83 | + | app_id: SyncAppId, | |
| 84 | + | user_id: UserId, | |
| 85 | + | ) -> Result<i64> { | |
| 86 | + | let count: i64 = sqlx::query_scalar( | |
| 87 | + | "SELECT COUNT(*) FROM sync_devices WHERE app_id = $1 AND user_id = $2", | |
| 88 | + | ) | |
| 89 | + | .bind(app_id) | |
| 90 | + | .bind(user_id) | |
| 91 | + | .fetch_one(pool) | |
| 92 | + | .await?; | |
| 93 | + | ||
| 94 | + | Ok(count) | |
| 95 | + | } | |
| 96 | + |
| @@ -0,0 +1,90 @@ | |||
| 1 | + | use sqlx::PgPool; | |
| 2 | + | ||
| 3 | + | use crate::db::enums::SyncPlatform; | |
| 4 | + | use crate::db::models::*; | |
| 5 | + | use crate::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 6 | + | use crate::error::Result; | |
| 7 | + | ||
| 8 | + | // ── Sync Devices ── | |
| 9 | + | ||
| 10 | + | /// Register or update a sync device. | |
| 11 | + | /// | |
| 12 | + | /// Upserts on the `(app_id, user_id, device_name)` unique constraint. | |
| 13 | + | /// On conflict (same device re-registering), updates the platform string | |
| 14 | + | /// and bumps `last_seen_at` so stale-device detection stays accurate. | |
| 15 | + | #[tracing::instrument(skip_all)] | |
| 16 | + | pub async fn upsert_sync_device( | |
| 17 | + | pool: &PgPool, | |
| 18 | + | app_id: SyncAppId, | |
| 19 | + | user_id: UserId, | |
| 20 | + | device_name: &str, | |
| 21 | + | platform: SyncPlatform, | |
| 22 | + | ) -> Result<DbSyncDevice> { | |
| 23 | + | let device = sqlx::query_as::<_, DbSyncDevice>( | |
| 24 | + | r#" | |
| 25 | + | INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 26 | + | VALUES ($1, $2, $3, $4) | |
| 27 | + | ON CONFLICT (app_id, user_id, device_name) | |
| 28 | + | DO UPDATE SET platform = EXCLUDED.platform, last_seen_at = NOW() | |
| 29 | + | RETURNING * | |
| 30 | + | "#, | |
| 31 | + | ) | |
| 32 | + | .bind(app_id) | |
| 33 | + | .bind(user_id) | |
| 34 | + | .bind(device_name) | |
| 35 | + | .bind(platform) | |
| 36 | + | .fetch_one(pool) | |
| 37 | + | .await?; | |
| 38 | + | ||
| 39 | + | Ok(device) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// List all devices for a user within an app. | |
| 43 | + | #[tracing::instrument(skip_all)] | |
| 44 | + | pub async fn get_sync_devices( | |
| 45 | + | pool: &PgPool, | |
| 46 | + | app_id: SyncAppId, | |
| 47 | + | user_id: UserId, | |
| 48 | + | ) -> Result<Vec<DbSyncDevice>> { | |
| 49 | + | let devices = sqlx::query_as::<_, DbSyncDevice>( | |
| 50 | + | "SELECT * FROM sync_devices WHERE app_id = $1 AND user_id = $2 ORDER BY last_seen_at DESC LIMIT 100", | |
| 51 | + | ) | |
| 52 | + | .bind(app_id) | |
| 53 | + | .bind(user_id) | |
| 54 | + | .fetch_all(pool) | |
| 55 | + | .await?; | |
| 56 | + | ||
| 57 | + | Ok(devices) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Delete a device by ID (only if owned by user within app). | |
| 61 | + | #[tracing::instrument(skip_all)] | |
| 62 | + | pub async fn delete_sync_device( | |
| 63 | + | pool: &PgPool, | |
| 64 | + | device_id: SyncDeviceId, | |
| 65 | + | app_id: SyncAppId, | |
| 66 | + | user_id: UserId, | |
| 67 | + | ) -> Result<bool> { | |
| 68 | + | let result = sqlx::query( | |
| 69 | + | "DELETE FROM sync_devices WHERE id = $1 AND app_id = $2 AND user_id = $3", | |
| 70 | + | ) | |
| 71 | + | .bind(device_id) | |
| 72 | + | .bind(app_id) | |
| 73 | + | .bind(user_id) | |
| 74 | + | .execute(pool) | |
| 75 | + | .await?; | |
| 76 | + | ||
| 77 | + | Ok(result.rows_affected() > 0) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Touch last_seen_at for a device. | |
| 81 | + | #[tracing::instrument(skip_all)] | |
| 82 | + | pub async fn touch_sync_device(pool: &PgPool, device_id: SyncDeviceId) -> Result<()> { | |
| 83 | + | sqlx::query("UPDATE sync_devices SET last_seen_at = NOW() WHERE id = $1") | |
| 84 | + | .bind(device_id) | |
| 85 | + | .execute(pool) | |
| 86 | + | .await?; | |
| 87 | + | ||
| 88 | + | Ok(()) | |
| 89 | + | } | |
| 90 | + |
| @@ -0,0 +1,250 @@ | |||
| 1 | + | use sqlx::PgPool; | |
| 2 | + | ||
| 3 | + | use crate::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 4 | + | use crate::error::Result; | |
| 5 | + | ||
| 6 | + | // ── Sync Keys ── | |
| 7 | + | ||
| 8 | + | /// Upsert an encrypted master key for a user within an app with optimistic | |
| 9 | + | /// concurrency control. | |
| 10 | + | /// | |
| 11 | + | /// `expected_version` is checked on UPDATE: if the current `key_version` | |
| 12 | + | /// doesn't match, the update is skipped and `false` is returned (the caller | |
| 13 | + | /// should return 409 Conflict). On INSERT (first key), `expected_version` | |
| 14 | + | /// must be 0. | |
| 15 | + | /// | |
| 16 | + | /// Returns `true` if the key was inserted or updated, `false` on version mismatch. | |
| 17 | + | #[tracing::instrument(skip_all)] | |
| 18 | + | pub async fn upsert_sync_key( | |
| 19 | + | pool: &PgPool, | |
| 20 | + | app_id: SyncAppId, | |
| 21 | + | user_id: UserId, | |
| 22 | + | encrypted_key: &str, | |
| 23 | + | expected_version: i32, | |
| 24 | + | ) -> Result<bool> { | |
| 25 | + | let result = sqlx::query( | |
| 26 | + | r#" | |
| 27 | + | INSERT INTO sync_keys (app_id, user_id, encrypted_key) | |
| 28 | + | VALUES ($1, $2, $3) | |
| 29 | + | ON CONFLICT (app_id, user_id) | |
| 30 | + | DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, | |
| 31 | + | key_version = sync_keys.key_version + 1, | |
| 32 | + | updated_at = NOW() | |
| 33 | + | WHERE sync_keys.key_version = $4 | |
| 34 | + | "#, | |
| 35 | + | ) | |
| 36 | + | .bind(app_id) | |
| 37 | + | .bind(user_id) | |
| 38 | + | .bind(encrypted_key) | |
| 39 | + | .bind(expected_version) | |
| 40 | + | .execute(pool) | |
| 41 | + | .await?; | |
| 42 | + | ||
| 43 | + | Ok(result.rows_affected() > 0) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Encryption key info returned by `get_sync_key`. | |
| 47 | + | pub struct SyncKeyInfo { | |
| 48 | + | pub encrypted_key: String, | |
| 49 | + | pub key_version: i32, | |
| 50 | + | pub key_id: i32, | |
| 51 | + | /// If a key rotation is in progress, the new key envelope and its key_id. | |
| 52 | + | pub pending_key: Option<(String, i32)>, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Get the encrypted master key, version, key_id, and any pending rotation key. | |
| 56 | + | #[tracing::instrument(skip_all)] | |
| 57 | + | pub async fn get_sync_key( | |
| 58 | + | pool: &PgPool, | |
| 59 | + | app_id: SyncAppId, | |
| 60 | + | user_id: UserId, | |
| 61 | + | ) -> Result<Option<SyncKeyInfo>> { | |
| 62 | + | let row: Option<(String, i32, i32)> = sqlx::query_as( | |
| 63 | + | "SELECT encrypted_key, key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2", | |
| 64 | + | ) | |
| 65 | + | .bind(app_id) | |
| 66 | + | .bind(user_id) | |
| 67 | + | .fetch_optional(pool) | |
| 68 | + | .await?; | |
| 69 | + | ||
| 70 | + | let Some((encrypted_key, key_version, key_id)) = row else { | |
| 71 | + | return Ok(None); | |
| 72 | + | }; | |
| 73 | + | ||
| 74 | + | // Check for an active rotation | |
| 75 | + | let pending: Option<(String, i32)> = sqlx::query_as( | |
| 76 | + | "SELECT new_encrypted_key, new_key_id FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2", | |
| 77 | + | ) | |
| 78 | + | .bind(app_id) | |
| 79 | + | .bind(user_id) | |
| 80 | + | .fetch_optional(pool) | |
| 81 | + | .await?; | |
| 82 | + | ||
| 83 | + | Ok(Some(SyncKeyInfo { | |
| 84 | + | encrypted_key, | |
| 85 | + | key_version, | |
| 86 | + | key_id, | |
| 87 | + | pending_key: pending, | |
| 88 | + | })) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Get device count and sync log entry count for all apps owned by a creator. | |
| 92 | + | /// Returns Vec of (app_id, device_count, log_entry_count). Single query replaces N+1 loop. | |
| 93 | + | #[tracing::instrument(skip_all)] | |
| 94 | + | pub async fn get_sync_app_stats_batch( | |
| 95 | + | pool: &PgPool, | |
| 96 | + | creator_id: UserId, | |
| 97 | + | ) -> Result<Vec<(SyncAppId, i64, i64)>> { | |
| 98 | + | let rows: Vec<(SyncAppId, i64, i64)> = sqlx::query_as( | |
| 99 | + | r#" | |
| 100 | + | SELECT | |
| 101 | + | a.id, | |
| 102 | + | (SELECT COUNT(*) FROM sync_devices d WHERE d.app_id = a.id), | |
| 103 | + | (SELECT COUNT(*) FROM sync_log l WHERE l.app_id = a.id) | |
| 104 | + | FROM sync_apps a | |
| 105 | + | WHERE a.creator_id = $1 | |
| 106 | + | "#, | |
| 107 | + | ) | |
| 108 | + | .bind(creator_id) | |
| 109 | + | .fetch_all(pool) | |
| 110 | + | .await?; | |
| 111 | + | ||
| 112 | + | Ok(rows) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Delete sync log entries older than the given number of days. | |
| 116 | + | /// | |
| 117 | + | /// `retain_days` must be positive. Returns 0 immediately for non-positive values | |
| 118 | + | /// to prevent accidental deletion of all entries. | |
| 119 | + | #[tracing::instrument(skip_all)] | |
| 120 | + | pub async fn prune_sync_log(pool: &PgPool, retain_days: i64) -> Result<u64> { | |
| 121 | + | if retain_days <= 0 { | |
| 122 | + | tracing::warn!("prune_sync_log called with non-positive retain_days={retain_days}, skipping"); | |
| 123 | + | return Ok(0); | |
| 124 | + | } | |
| 125 | + | let result = sqlx::query( | |
| 126 | + | "DELETE FROM sync_log WHERE created_at < NOW() - make_interval(days => $1)", | |
| 127 | + | ) | |
| 128 | + | .bind(retain_days) | |
| 129 | + | .execute(pool) | |
| 130 | + | .await?; | |
| 131 | + | ||
| 132 | + | Ok(result.rows_affected()) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Update a device's last-pulled cursor position. | |
| 136 | + | #[tracing::instrument(skip_all)] | |
| 137 | + | pub async fn update_device_cursor( | |
| 138 | + | pool: &PgPool, | |
| 139 | + | device_id: SyncDeviceId, | |
| 140 | + | seq: i64, | |
| 141 | + | ) -> Result<()> { | |
| 142 | + | sqlx::query( | |
| 143 | + | "UPDATE sync_devices SET last_pulled_seq = GREATEST(last_pulled_seq, $1) WHERE id = $2", | |
| 144 | + | ) | |
| 145 | + | .bind(seq) | |
| 146 | + | .bind(device_id) | |
| 147 | + | .execute(pool) | |
| 148 | + | .await?; | |
| 149 | + | Ok(()) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// Compact the sync log by removing entries that all devices for a given | |
| 153 | + | /// (app_id, user_id) have already pulled. Keeps a safety margin of entries | |
| 154 | + | /// newer than `min_age_days` regardless of cursor positions. | |
| 155 | + | /// | |
| 156 | + | /// Returns the number of entries deleted. | |
| 157 | + | #[allow(dead_code)] // Public API for targeted per-user compaction | |
| 158 | + | #[tracing::instrument(skip_all)] | |
| 159 | + | pub async fn compact_sync_log( | |
| 160 | + | pool: &PgPool, | |
| 161 | + | app_id: SyncAppId, | |
| 162 | + | user_id: UserId, | |
| 163 | + | min_age_days: i64, | |
| 164 | + | ) -> Result<u64> { | |
| 165 | + | if min_age_days <= 0 { | |
| 166 | + | return Ok(0); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // Find the lowest cursor across all devices for this user+app. | |
| 170 | + | // Entries below this seq have been pulled by every device. | |
| 171 | + | let min_cursor: Option<i64> = sqlx::query_scalar( | |
| 172 | + | "SELECT MIN(last_pulled_seq) FROM sync_devices WHERE app_id = $1 AND user_id = $2", | |
| 173 | + | ) | |
| 174 | + | .bind(app_id) | |
| 175 | + | .bind(user_id) | |
| 176 | + | .fetch_one(pool) | |
| 177 | + | .await?; | |
| 178 | + | ||
| 179 | + | let safe_seq = match min_cursor { | |
| 180 | + | Some(seq) if seq > 0 => seq, | |
| 181 | + | _ => return Ok(0), // No devices or no pulls yet | |
| 182 | + | }; | |
| 183 | + | ||
| 184 | + | // Delete entries below the safe cursor AND older than the safety margin. | |
| 185 | + | let result = sqlx::query( | |
| 186 | + | r#" | |
| 187 | + | DELETE FROM sync_log | |
| 188 | + | WHERE app_id = $1 AND user_id = $2 | |
| 189 | + | AND seq <= $3 | |
| 190 | + | AND created_at < NOW() - make_interval(days => $4) | |
| 191 | + | "#, | |
| 192 | + | ) | |
| 193 | + | .bind(app_id) | |
| 194 | + | .bind(user_id) | |
| 195 | + | .bind(safe_seq) | |
| 196 | + | .bind(min_age_days) | |
| 197 | + | .execute(pool) | |
| 198 | + | .await?; | |
| 199 | + | ||
| 200 | + | Ok(result.rows_affected()) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// Compact sync logs for all user+app pairs that have compactable entries. | |
| 204 | + | /// Finds pairs where MIN(last_pulled_seq) across devices > 0, then deletes | |
| 205 | + | /// entries below that threshold (with age safety margin). | |
| 206 | + | /// | |
| 207 | + | /// Returns total entries deleted across all users. | |
| 208 | + | #[tracing::instrument(skip_all)] | |
| 209 | + | pub async fn compact_all_sync_logs(pool: &PgPool, min_age_days: i64) -> Result<u64> { | |
| 210 | + | if min_age_days <= 0 { | |
| 211 | + | return Ok(0); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // Find (app_id, user_id) pairs where compaction is possible: | |
| 215 | + | // all devices have pulled past seq 0, and there are old entries to delete. | |
| 216 | + | let pairs: Vec<(SyncAppId, UserId, i64)> = sqlx::query_as( | |
| 217 | + | r#" | |
| 218 | + | SELECT app_id, user_id, MIN(last_pulled_seq) AS min_seq | |
| 219 | + | FROM sync_devices | |
| 220 | + | WHERE last_pulled_seq > 0 | |
| 221 | + | GROUP BY app_id, user_id | |
| 222 | + | HAVING MIN(last_pulled_seq) > 0 | |
| 223 | + | "#, | |
| 224 | + | ) | |
| 225 | + | .fetch_all(pool) | |
| 226 | + | .await?; | |
| 227 | + | ||
| 228 | + | let mut total_deleted: u64 = 0; | |
| 229 | + | for (app_id, user_id, safe_seq) in pairs { | |
| 230 | + | let result = sqlx::query( | |
| 231 | + | r#" | |
| 232 | + | DELETE FROM sync_log | |
| 233 | + | WHERE app_id = $1 AND user_id = $2 | |
| 234 | + | AND seq <= $3 | |
| 235 | + | AND created_at < NOW() - make_interval(days => $4) | |
| 236 | + | "#, | |
| 237 | + | ) | |
| 238 | + | .bind(app_id) | |
| 239 | + | .bind(user_id) | |
| 240 | + | .bind(safe_seq) | |
| 241 | + | .bind(min_age_days) | |
| 242 | + | .execute(pool) | |
| 243 | + | .await?; | |
| 244 | + | ||
| 245 | + | total_deleted += result.rows_affected(); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | Ok(total_deleted) | |
| 249 | + | } | |
| 250 | + |
| @@ -0,0 +1,171 @@ | |||
| 1 | + | use serde_json::Value as JsonValue; | |
| 2 | + | use sqlx::PgPool; | |
| 3 | + | ||
| 4 | + | use crate::db::models::*; | |
| 5 | + | use crate::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 6 | + | use crate::error::Result; | |
| 7 | + | ||
| 8 | + | // ── Sync Log ── | |
| 9 | + | ||
| 10 | + | /// Push a batch of changes to the sync log. Returns the highest seq assigned. | |
| 11 | + | /// | |
| 12 | + | /// `batch_id` is a client-generated UUID for idempotent push. If a batch with | |
| 13 | + | /// the same ID has already been committed for this app+user, the existing max | |
| 14 | + | /// seq is returned without re-inserting (at-most-once semantics). | |
| 15 | + | /// | |
| 16 | + | /// All changes are inserted within a single transaction for atomicity and | |
| 17 | + | /// performance (one fsync instead of N). | |
| 18 | + | #[allow(clippy::type_complexity)] | |
| 19 | + | #[tracing::instrument(skip_all)] | |
| 20 | + | pub async fn push_sync_changes( | |
| 21 | + | pool: &PgPool, | |
| 22 | + | app_id: SyncAppId, | |
| 23 | + | user_id: UserId, | |
| 24 | + | device_id: SyncDeviceId, | |
| 25 | + | batch_id: uuid::Uuid, | |
| 26 | + | changes: &[(String, String, String, chrono::DateTime<chrono::Utc>, Option<JsonValue>)], | |
| 27 | + | ) -> Result<i64> { | |
| 28 | + | if changes.is_empty() { | |
| 29 | + | return Ok(0); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Decompose into parallel Vecs for UNNEST-based batch INSERT | |
| 33 | + | let mut table_names: Vec<String> = Vec::with_capacity(changes.len()); | |
| 34 | + | let mut operations: Vec<String> = Vec::with_capacity(changes.len()); | |
| 35 | + | let mut row_ids: Vec<String> = Vec::with_capacity(changes.len()); | |
| 36 | + | let mut client_timestamps: Vec<chrono::DateTime<chrono::Utc>> = Vec::with_capacity(changes.len()); | |
| 37 | + | let mut data_values: Vec<JsonValue> = Vec::with_capacity(changes.len()); | |
| 38 | + | ||
| 39 | + | for (table_name, operation, row_id, client_timestamp, data) in changes { | |
| 40 | + | table_names.push(table_name.clone()); | |
| 41 | + | operations.push(operation.clone()); | |
| 42 | + | row_ids.push(row_id.clone()); | |
| 43 | + | client_timestamps.push(*client_timestamp); | |
| 44 | + | data_values.push(data.clone().unwrap_or(JsonValue::Null)); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | let mut tx = pool.begin().await?; | |
| 48 | + | ||
| 49 | + | // Idempotency check inside the transaction to prevent TOCTOU race. | |
| 50 | + | let existing: (Option<i64>,) = sqlx::query_as( | |
| 51 | + | "SELECT MAX(seq) FROM sync_log WHERE app_id = $1 AND user_id = $2 AND batch_id = $3", | |
| 52 | + | ) | |
| 53 | + | .bind(app_id) | |
| 54 | + | .bind(user_id) | |
| 55 | + | .bind(batch_id) | |
| 56 | + | .fetch_one(&mut *tx) | |
| 57 | + | .await?; | |
| 58 | + | ||
| 59 | + | if let Some(max_seq) = existing.0 { | |
| 60 | + | tracing::debug!(batch_id = %batch_id, cursor = max_seq, "Push batch already committed, returning existing cursor"); | |
| 61 | + | tx.rollback().await.ok(); | |
| 62 | + | return Ok(max_seq); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Read the current key_id from sync_keys so pushed entries are stamped | |
| 66 | + | // with the active encryption key. Falls back to NULL if no key exists yet | |
| 67 | + | // (pre-encryption setup — entries have no encrypted data anyway). | |
| 68 | + | let current_key_id: Option<i32> = sqlx::query_scalar( | |
| 69 | + | "SELECT key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2", | |
| 70 | + | ) | |
| 71 | + | .bind(app_id) | |
| 72 | + | .bind(user_id) | |
| 73 | + | .fetch_optional(&mut *tx) | |
| 74 | + | .await?; | |
| 75 | + | ||
| 76 | + | let seqs: Vec<i64> = sqlx::query_scalar( | |
| 77 | + | r#" | |
| 78 | + | INSERT INTO sync_log (app_id, user_id, device_id, batch_id, table_name, operation, row_id, client_timestamp, data, key_id) | |
| 79 | + | SELECT $1, $2, $3, $4, t.*, $10 | |
| 80 | + | FROM UNNEST($5::text[], $6::text[], $7::text[], $8::timestamptz[], $9::jsonb[]) AS t | |
| 81 | + | RETURNING seq | |
| 82 | + | "#, | |
| 83 | + | ) | |
| 84 | + | .bind(app_id) | |
| 85 | + | .bind(user_id) | |
| 86 | + | .bind(device_id) | |
| 87 | + | .bind(batch_id) | |
| 88 | + | .bind(&table_names) | |
| 89 | + | .bind(&operations) | |
| 90 | + | .bind(&row_ids) | |
| 91 | + | .bind(&client_timestamps) | |
| 92 | + | .bind(&data_values) | |
| 93 | + | .bind(current_key_id) | |
| 94 | + | .fetch_all(&mut *tx) | |
| 95 | + | .await?; | |
| 96 | + | ||
| 97 | + | tx.commit().await?; | |
| 98 | + | ||
| 99 | + | let max_seq = seqs.iter().copied().max().unwrap_or(0); | |
| 100 | + | ||
| 101 | + | Ok(max_seq) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Pull changes since a cursor for a user within an app. | |
| 105 | + | /// | |
| 106 | + | /// Prefer `pull_sync_changes_filtered` for new code — it supports optional | |
| 107 | + | /// table and timestamp filters. This function is kept for backward compatibility. | |
| 108 | + | #[allow(dead_code)] | |
| 109 | + | #[tracing::instrument(skip_all)] | |
| 110 | + | pub async fn pull_sync_changes( | |
| 111 | + | pool: &PgPool, | |
| 112 | + | app_id: SyncAppId, | |
| 113 | + | user_id: UserId, | |
| 114 | + | cursor: i64, | |
| 115 | + | limit: i64, | |
| 116 | + | ) -> Result<Vec<DbSyncLogEntry>> { | |
| 117 | + | let entries = sqlx::query_as::<_, DbSyncLogEntry>( | |
| 118 | + | r#" | |
| 119 | + | SELECT * FROM sync_log | |
| 120 | + | WHERE app_id = $1 AND user_id = $2 AND seq > $3 | |
| 121 | + | ORDER BY seq ASC | |
| 122 | + | LIMIT $4 | |
| 123 | + | "#, | |
| 124 | + | ) | |
| 125 | + | .bind(app_id) | |
| 126 | + | .bind(user_id) | |
| 127 | + | .bind(cursor) | |
| 128 | + | .bind(limit) | |
| 129 | + | .fetch_all(pool) | |
| 130 | + | .await?; | |
| 131 | + | ||
| 132 | + | Ok(entries) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Pull changes since a cursor with optional table and timestamp filters. | |
| 136 | + | /// | |
| 137 | + | /// When `tables` is `Some`, only entries whose `table_name` is in the list are returned. | |
| 138 | + | /// When `since` is `Some`, only entries with `client_timestamp >= since` are returned. | |
| 139 | + | /// Both filters compose (AND). Passing `None` for both is identical to `pull_sync_changes`. | |
| 140 | + | #[tracing::instrument(skip_all)] | |
| 141 | + | pub async fn pull_sync_changes_filtered( | |
| 142 | + | pool: &PgPool, | |
| 143 | + | app_id: SyncAppId, | |
| 144 | + | user_id: UserId, | |
| 145 | + | cursor: i64, | |
| 146 | + | limit: i64, | |
| 147 | + | tables: Option<&[String]>, | |
| 148 | + | since: Option<chrono::DateTime<chrono::Utc>>, | |
| 149 | + | ) -> Result<Vec<DbSyncLogEntry>> { | |
| 150 | + | let entries = sqlx::query_as::<_, DbSyncLogEntry>( | |
| 151 | + | r#" | |
| 152 | + | SELECT * FROM sync_log | |
| 153 | + | WHERE app_id = $1 AND user_id = $2 AND seq > $3 | |
| 154 | + | AND ($5::text[] IS NULL OR table_name = ANY($5)) | |
| 155 | + | AND ($6::timestamptz IS NULL OR client_timestamp >= $6) | |
| 156 | + | ORDER BY seq ASC | |
| 157 | + | LIMIT $4 | |
| 158 | + | "#, | |
| 159 | + | ) | |
| 160 | + | .bind(app_id) | |
| 161 | + | .bind(user_id) | |
| 162 | + | .bind(cursor) | |
| 163 | + | .bind(limit) | |
| 164 | + | .bind(tables) | |
| 165 | + | .bind(since) | |
| 166 | + | .fetch_all(pool) | |
| 167 | + | .await?; | |
| 168 | + | ||
| 169 | + | Ok(entries) | |
| 170 | + | } | |
| 171 | + |
| @@ -0,0 +1,19 @@ | |||
| 1 | + | //! SyncKit: device registration, change log, blob storage, and encryption key | |
| 2 | + | //! management. | |
| 3 | + | //! | |
| 4 | + | //! Split by domain into submodules — all functions are re-exported flat so | |
| 5 | + | //! call sites keep using `db::synckit::<fn_name>(...)`. | |
| 6 | + | ||
| 7 | + | mod apps; | |
| 8 | + | mod blobs; | |
| 9 | + | mod devices; | |
| 10 | + | mod keys; | |
| 11 | + | mod log; | |
| 12 | + | mod rotation; | |
| 13 | + | ||
| 14 | + | pub use apps::*; | |
| 15 | + | pub use blobs::*; | |
| 16 | + | pub use devices::*; | |
| 17 | + | pub use keys::*; | |
| 18 | + | pub use log::*; | |
| 19 | + | pub use rotation::*; |
| @@ -0,0 +1,320 @@ | |||
| 1 | + | use serde_json::Value as JsonValue; | |
| 2 | + | use sqlx::PgPool; | |
| 3 | + | ||
| 4 | + | use crate::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 5 | + | use crate::error::Result; | |
| 6 | + | ||
| 7 | + | // ── Key Rotation ── | |
| 8 | + | ||
| 9 | + | /// Begin a key rotation. Returns the rotation row if created, or the existing | |
| 10 | + | /// row if this device already has an active rotation (resume support). | |
| 11 | + | /// | |
| 12 | + | /// Returns `None` if the key_version doesn't match (caller should 409). | |
| 13 | + | /// Returns `Err` if a different device has an active rotation. | |
| 14 | + | #[tracing::instrument(skip_all)] | |
| 15 | + | pub async fn begin_key_rotation( | |
| 16 | + | pool: &PgPool, | |
| 17 | + | app_id: SyncAppId, | |
| 18 | + | user_id: UserId, | |
| 19 | + | device_id: SyncDeviceId, | |
| 20 | + | new_encrypted_key: &str, | |
| 21 | + | expected_key_version: i32, | |
| 22 | + | ) -> Result<std::result::Result<crate::db::models::DbSyncKeyRotation, &'static str>> { | |
| 23 | + | // Verify key_version matches | |
| 24 | + | let key_row: Option<(i32, i32)> = sqlx::query_as( | |
| 25 | + | "SELECT key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2", | |
| 26 | + | ) | |
| 27 | + | .bind(app_id) | |
| 28 | + | .bind(user_id) | |
| 29 | + | .fetch_optional(pool) | |
| 30 | + | .await?; | |
| 31 | + | ||
| 32 | + | let Some((current_version, current_key_id)) = key_row else { | |
| 33 | + | return Ok(Err("no encryption key exists")); | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | if current_version != expected_key_version { | |
| 37 | + | return Ok(Err("key version mismatch")); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Check for existing rotation | |
| 41 | + | let existing = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>( | |
| 42 | + | "SELECT * FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2", | |
| 43 | + | ) | |
| 44 | + | .bind(app_id) | |
| 45 | + | .bind(user_id) | |
| 46 | + | .fetch_optional(pool) | |
| 47 | + | .await?; | |
| 48 | + | ||
| 49 | + | if let Some(rotation) = existing { | |
| 50 | + | if rotation.device_id == device_id { | |
| 51 | + | // Same device resuming — return existing rotation | |
| 52 | + | return Ok(Ok(rotation)); | |
| 53 | + | } | |
| 54 | + | return Ok(Err("rotation already in progress by another device")); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Get target_seq (max seq for this user) | |
| 58 | + | let target_seq: i64 = sqlx::query_scalar( | |
| 59 | + | "SELECT COALESCE(MAX(seq), 0) FROM sync_log WHERE app_id = $1 AND user_id = $2", | |
| 60 | + | ) | |
| 61 | + | .bind(app_id) | |
| 62 | + | .bind(user_id) | |
| 63 | + | .fetch_one(pool) | |
| 64 | + | .await?; | |
| 65 | + | ||
| 66 | + | let new_key_id = current_key_id + 1; | |
| 67 | + | ||
| 68 | + | let rotation = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>( | |
| 69 | + | r#" | |
| 70 | + | INSERT INTO sync_key_rotations (app_id, user_id, device_id, new_encrypted_key, old_key_version, new_key_id, target_seq) | |
| 71 | + | VALUES ($1, $2, $3, $4, $5, $6, $7) | |
| 72 | + | RETURNING * | |
| 73 | + | "#, | |
| 74 | + | ) | |
| 75 | + | .bind(app_id) | |
| 76 | + | .bind(user_id) | |
| 77 | + | .bind(device_id) | |
| 78 | + | .bind(new_encrypted_key) | |
| 79 | + | .bind(current_version) | |
| 80 | + | .bind(new_key_id) | |
| 81 | + | .bind(target_seq) | |
| 82 | + | .fetch_one(pool) | |
| 83 | + | .await?; | |
| 84 | + | ||
| 85 | + | Ok(Ok(rotation)) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Get the active rotation for a user, if any. | |
| 89 | + | #[tracing::instrument(skip_all)] | |
| 90 | + | pub async fn get_key_rotation( | |
| 91 | + | pool: &PgPool, | |
| 92 | + | app_id: SyncAppId, | |
| 93 | + | user_id: UserId, | |
| 94 | + | ) -> Result<Option<crate::db::models::DbSyncKeyRotation>> { | |
| 95 | + | let rotation = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>( | |
| 96 | + | "SELECT * FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2", | |
| 97 | + | ) | |
| 98 | + | .bind(app_id) | |
| 99 | + | .bind(user_id) | |
| 100 | + | .fetch_optional(pool) | |
| 101 | + | .await?; | |
| 102 | + | ||
| 103 | + | Ok(rotation) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Pull sync log entries that need re-encryption (key_id != new_key_id). | |
| 107 | + | /// Returns entries ordered by seq, paginated by after_seq. | |
| 108 | + | #[tracing::instrument(skip_all)] | |
| 109 | + | pub async fn get_rotation_entries( | |
| 110 | + | pool: &PgPool, | |
| 111 | + | app_id: SyncAppId, | |
| 112 | + | user_id: UserId, | |
| 113 | + | new_key_id: i32, | |
| 114 | + | after_seq: i64, | |
| 115 | + | limit: i64, | |
| 116 | + | ) -> Result<Vec<(i64, Option<JsonValue>)>> { | |
| 117 | + | let entries: Vec<(i64, Option<JsonValue>)> = sqlx::query_as( | |
| 118 | + | r#" | |
| 119 | + | SELECT seq, data FROM sync_log | |
| 120 | + | WHERE app_id = $1 AND user_id = $2 | |
| 121 | + | AND seq > $3 | |
| 122 | + | AND (key_id IS NULL OR key_id != $5) | |
| 123 | + | ORDER BY seq ASC | |
| 124 | + | LIMIT $4 | |
| 125 | + | "#, | |
| 126 | + | ) | |
| 127 | + | .bind(app_id) | |
| 128 | + | .bind(user_id) | |
| 129 | + | .bind(after_seq) | |
| 130 | + | .bind(limit) | |
| 131 | + | .bind(new_key_id) | |
| 132 | + | .fetch_all(pool) | |
| 133 | + | .await?; | |
| 134 | + | ||
| 135 | + | Ok(entries) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Batch-update re-encrypted sync log entries during key rotation. | |
| 139 | + | /// Sets data and key_id for each (seq) in the batch. | |
| 140 | + | /// Returns the number of rows updated. | |
| 141 | + | #[tracing::instrument(skip_all)] | |
| 142 | + | pub async fn submit_rotation_batch( | |
| 143 | + | pool: &PgPool, | |
| 144 | + | app_id: SyncAppId, | |
| 145 | + | user_id: UserId, | |
| 146 | + | rotation_id: uuid::Uuid, | |
| 147 | + | new_key_id: i32, | |
| 148 | + | entries: &[(i64, Option<JsonValue>)], | |
| 149 | + | ) -> Result<u64> { | |
| 150 | + | if entries.is_empty() { | |
| 151 | + | return Ok(0); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | let mut seqs: Vec<i64> = Vec::with_capacity(entries.len()); | |
| 155 | + | let mut data_values: Vec<JsonValue> = Vec::with_capacity(entries.len()); | |
| 156 | + | for (seq, data) in entries { | |
| 157 | + | seqs.push(*seq); | |
| 158 | + | data_values.push(data.clone().unwrap_or(JsonValue::Null)); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | let mut tx = pool.begin().await?; | |
| 162 | + | ||
| 163 | + | let updated = sqlx::query( | |
| 164 | + | r#" | |
| 165 | + | UPDATE sync_log AS sl | |
| 166 | + | SET data = CASE WHEN batch.new_data = 'null'::jsonb THEN NULL ELSE batch.new_data END, | |
| 167 | + | key_id = $3 | |
| 168 | + | FROM UNNEST($4::bigint[], $5::jsonb[]) AS batch(seq, new_data) | |
| 169 | + | WHERE sl.seq = batch.seq AND sl.app_id = $1 AND sl.user_id = $2 | |
| 170 | + | "#, | |
| 171 | + | ) | |
| 172 | + | .bind(app_id) | |
| 173 | + | .bind(user_id) | |
| 174 | + | .bind(new_key_id) | |
| 175 | + | .bind(&seqs) | |
| 176 | + | .bind(&data_values) | |
| 177 | + | .execute(&mut *tx) | |
| 178 | + | .await?; | |
| 179 | + | ||
| 180 | + | // Update progress marker | |
| 181 | + | if let Some(&max_seq) = seqs.iter().max() { | |
| 182 | + | sqlx::query( | |
| 183 | + | r#" | |
| 184 | + | UPDATE sync_key_rotations | |
| 185 | + | SET re_encrypted_through_seq = GREATEST(re_encrypted_through_seq, $1), | |
| 186 | + | updated_at = NOW() | |
| 187 | + | WHERE id = $2 | |
| 188 | + | "#, | |
| 189 | + | ) | |
| 190 | + | .bind(max_seq) | |
| 191 | + | .bind(rotation_id) | |
| 192 | + | .execute(&mut *tx) | |
| 193 | + | .await?; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | tx.commit().await?; | |
| 197 | + | ||
| 198 | + | Ok(updated.rows_affected()) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Complete a key rotation: swap the new key into sync_keys and delete the rotation. | |
| 202 | + | /// Returns `Err("entries remain")` if un-rotated entries still exist, with the count. | |
| 203 | + | #[tracing::instrument(skip_all)] | |
| 204 | + | pub async fn complete_key_rotation( | |
| 205 | + | pool: &PgPool, | |
| 206 | + | app_id: SyncAppId, | |
| 207 | + | user_id: UserId, | |
| 208 | + | rotation_id: uuid::Uuid, | |
| 209 | + | ) -> Result<std::result::Result<i32, i64>> { | |
| 210 | + | let rotation = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>( | |
| 211 | + | "SELECT * FROM sync_key_rotations WHERE id = $1 AND app_id = $2 AND user_id = $3", | |
| 212 | + | ) | |
| 213 | + | .bind(rotation_id) | |
| 214 | + | .bind(app_id) | |
| 215 | + | .bind(user_id) | |
| 216 | + | .fetch_optional(pool) | |
| 217 | + | .await?; | |
| 218 | + | ||
| 219 | + | let Some(rotation) = rotation else { | |
| 220 | + | return Ok(Err(0)); // No rotation found | |
| 221 | + | }; | |
| 222 | + | ||
| 223 | + | // Check for remaining un-rotated entries up to the target_seq captured at | |
| 224 | + | // rotation start. Entries arriving after rotation began are excluded — they | |
| 225 | + | // will use the new key once rotation completes. | |
| 226 | + | let remaining: i64 = sqlx::query_scalar( | |
| 227 | + | r#" | |
| 228 | + | SELECT COUNT(*) FROM sync_log | |
| 229 | + | WHERE app_id = $1 AND user_id = $2 | |
| 230 | + | AND seq <= $4 | |
| 231 | + | AND (key_id IS NULL OR key_id != $3) | |
| 232 | + | "#, | |
| 233 | + | ) | |
| 234 | + | .bind(app_id) | |
| 235 | + | .bind(user_id) | |
| 236 | + | .bind(rotation.new_key_id) | |
| 237 | + | .bind(rotation.target_seq) | |
| 238 | + | .fetch_one(pool) | |
| 239 | + | .await?; | |
| 240 | + | ||
| 241 | + | if remaining > 0 { | |
| 242 | + | return Ok(Err(remaining)); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // Atomically swap key and delete rotation | |
| 246 | + | let mut tx = pool.begin().await?; | |
| 247 | + | ||
| 248 | + | sqlx::query( | |
| 249 | + | r#" | |
| 250 | + | UPDATE sync_keys | |
| 251 | + | SET encrypted_key = $3, | |
| 252 | + | key_version = key_version + 1, | |
| 253 | + | key_id = $4, | |
| 254 | + | updated_at = NOW() | |
| 255 | + | WHERE app_id = $1 AND user_id = $2 | |
| 256 | + | "#, | |
| 257 | + | ) | |
| 258 | + | .bind(app_id) | |
| 259 | + | .bind(user_id) | |
| 260 | + | .bind(&rotation.new_encrypted_key) | |
| 261 | + | .bind(rotation.new_key_id) | |
| 262 | + | .execute(&mut *tx) | |
| 263 | + | .await?; | |
| 264 | + | ||
| 265 | + | sqlx::query("DELETE FROM sync_key_rotations WHERE id = $1") | |
| 266 | + | .bind(rotation_id) | |
| 267 | + | .execute(&mut *tx) | |
| 268 | + | .await?; | |
| 269 | + | ||
| 270 | + | tx.commit().await?; | |
| 271 | + | ||
| 272 | + | Ok(Ok(rotation.new_key_id)) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /// Cancel a stale rotation (only if older than the stale threshold). | |
| 276 | + | /// Returns true if cancelled, false if not found or not stale. | |
| 277 | + | #[tracing::instrument(skip_all)] | |
| 278 | + | pub async fn cancel_stale_rotation( | |
| 279 | + | pool: &PgPool, | |
| 280 | + | app_id: SyncAppId, | |
| 281 | + | user_id: UserId, | |
| 282 | + | stale_hours: i64, | |
| 283 | + | ) -> Result<bool> { | |
| 284 | + | let result = sqlx::query( | |
| 285 | + | r#" | |
| 286 | + | DELETE FROM sync_key_rotations | |
| 287 | + | WHERE app_id = $1 AND user_id = $2 | |
| 288 | + | AND updated_at < NOW() - make_interval(hours => $3) | |
| 289 | + | "#, | |
| 290 | + | ) | |
| 291 | + | .bind(app_id) | |
| 292 | + | .bind(user_id) | |
| 293 | + | .bind(stale_hours) | |
| 294 | + | .execute(pool) | |
| 295 | + | .await?; | |
| 296 | + | ||
| 297 | + | Ok(result.rows_affected() > 0) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// Get sync status: total changes and latest seq for a user within an app. | |
| 301 | + | #[tracing::instrument(skip_all)] | |
| 302 | + | pub async fn get_sync_status( | |
| 303 | + | pool: &PgPool, | |
| 304 | + | app_id: SyncAppId, | |
| 305 | + | user_id: UserId, | |
| 306 | + | ) -> Result<(i64, Option<i64>)> { | |
| 307 | + | let row: (i64, Option<i64>) = sqlx::query_as( | |
| 308 | + | r#" | |
| 309 | + | SELECT COUNT(*), MAX(seq) | |
| 310 | + | FROM sync_log | |
| 311 | + | WHERE app_id = $1 AND user_id = $2 | |
| 312 | + | "#, | |
| 313 | + | ) | |
| 314 | + | .bind(app_id) | |
| 315 | + | .bind(user_id) | |
| 316 | + | .fetch_one(pool) | |
| 317 | + | .await?; | |
| 318 | + | ||
| 319 | + | Ok(row) | |
| 320 | + | } |
| @@ -72,51 +72,6 @@ pub async fn get_tags_for_items( | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 | } |
| @@ -1,1088 +0,0 @@ | |||
| 1 | - | //! Templates for public-facing pages: landing, auth, content, blog, discover. | |
| 2 | - | ||
| 3 | - | use std::sync::Arc; | |
| 4 | - | ||
| 5 | - | use askama::Template; | |
| 6 | - | ||
| 7 | - | use crate::auth::SessionUser; | |
| 8 | - | use crate::git; | |
| 9 | - | use crate::types::*; | |
| 10 | - | ||
| 11 | - | use super::CsrfTokenOption; | |
| 12 | - | ||
| 13 | - | // ============================================================================ | |
| 14 | - | // Public Pages | |
| 15 | - | // ============================================================================ | |
| 16 | - | ||
| 17 | - | /// Sandbox info page explaining the ephemeral demo mode. | |
| 18 | - | #[derive(Template)] | |
| 19 | - | #[template(path = "pages/sandbox.html")] | |
| 20 | - | pub struct SandboxTemplate { | |
| 21 | - | pub csrf_token: CsrfTokenOption, | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | /// Content policy page. | |
| 25 | - | #[derive(Template)] | |
| 26 | - | #[template(path = "pages/policy.html")] | |
| 27 | - | pub struct PolicyTemplate { | |
| 28 | - | /// CSRF token injected into forms; `None` on public pages that have no forms. | |
| 29 | - | pub csrf_token: CsrfTokenOption, | |
| 30 | - | /// Logged-in user context for the site header; `None` when not authenticated. | |
| 31 | - | pub session_user: Option<SessionUser>, | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | /// Landing page. | |
| 35 | - | #[derive(Template)] | |
| 36 | - | #[template(path = "pages/index.html")] | |
| 37 | - | pub struct IndexTemplate { | |
| 38 | - | pub csrf_token: CsrfTokenOption, | |
| 39 | - | pub host_url: Arc<str>, | |
| 40 | - | pub total_creators: u32, | |
| 41 | - | pub total_items: u32, | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | /// User's library shell with inline purchases tab (other tabs loaded via HTMX). | |
| 45 | - | #[derive(Template)] | |
| 46 | - | #[template(path = "pages/library.html")] | |
| 47 | - | pub struct LibraryTemplate { | |
| 48 | - | pub csrf_token: CsrfTokenOption, | |
| 49 | - | pub session_user: Option<SessionUser>, | |
| 50 | - | pub purchases: Vec<crate::db::DbPurchaseRow>, | |
| 51 | - | pub subscriptions: Vec<UserSubscription>, | |
| 52 | - | pub has_mt_memberships: bool, | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | /// Shopping cart page with items grouped by seller. | |
| 56 | - | #[derive(Template)] | |
| 57 | - | #[template(path = "pages/cart.html")] | |
| 58 | - | pub struct CartTemplate { | |
| 59 | - | pub csrf_token: CsrfTokenOption, | |
| 60 | - | pub session_user: Option<SessionUser>, | |
| 61 | - | pub seller_groups: Vec<CartSellerGroup>, | |
| 62 | - | pub wishlist_suggestions: Vec<crate::db::wishlists::WishlistItem>, | |
| 63 | - | pub total_items: usize, | |
| 64 | - | /// Set to "partial" when a multi-seller checkout partially succeeded. | |
| 65 | - | pub checkout_status: String, | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | /// A group of cart items from the same seller. | |
| 69 | - | pub struct CartSellerGroup { | |
| 70 | - | pub seller_username: String, | |
| 71 | - | pub seller_id: String, | |
| 72 | - | pub stripe_ready: bool, | |
| 73 | - | pub items: Vec<crate::db::cart::CartItem>, | |
| 74 | - | pub subtotal_cents: i32, | |
| 75 | - | pub item_count: usize, | |
| 76 | - | /// How much the creator saves vs. individual purchases ($0.30 per extra item). | |
| 77 | - | pub savings_cents: i32, | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | /// Login page. | |
| 81 | - | #[derive(Template)] | |
| 82 | - | #[template(path = "pages/login.html")] | |
| 83 | - | pub struct LoginTemplate { | |
| 84 | - | pub csrf_token: CsrfTokenOption, | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | // ============================================================================ | |
| 88 | - | // Join Wizard | |
| 89 | - | // ============================================================================ | |
| 90 | - | ||
| 91 | - | /// Full page: join/signup wizard. | |
| 92 | - | #[derive(Template)] | |
| 93 | - | #[template(path = "wizards/wizard_join.html")] | |
| 94 | - | pub struct WizardJoinTemplate { | |
| 95 | - | pub csrf_token: CsrfTokenOption, | |
| 96 | - | pub nav: Vec<super::StepNavItem>, | |
| 97 | - | pub invite_code: Option<String>, | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | /// Step 1 partial: account creation (for back-nav reload). | |
| 101 | - | #[derive(Template)] | |
| 102 | - | #[template(path = "wizards/steps/join/account.html")] | |
| 103 | - | pub struct WizardJoinAccountTemplate { | |
| 104 | - | pub nav: Vec<super::StepNavItem>, | |
| 105 | - | pub csrf_token: CsrfTokenOption, | |
| 106 | - | pub invite_code: Option<String>, | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | /// Step 2 partial: profile (display name + bio). | |
| 110 | - | #[derive(Template)] | |
| 111 | - | #[template(path = "wizards/steps/join/profile.html")] | |
| 112 | - | pub struct WizardJoinProfileTemplate { | |
| 113 | - | pub nav: Vec<super::StepNavItem>, | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | /// Step 3 partial: welcome/complete with intent branching. | |
| 117 | - | #[derive(Template)] | |
| 118 | - | #[template(path = "wizards/steps/join/complete.html")] | |
| 119 | - | pub struct WizardJoinCompleteTemplate { | |
| 120 | - | pub nav: Vec<super::StepNavItem>, | |
| 121 | - | pub display_name: String, | |
| 122 | - | /// Whether this user already has creator access. | |
| 123 | - | pub is_creator: bool, | |
| 124 | - | /// Whether this user arrived via invite (already has waitlist entry). | |
| 125 | - | pub has_invite: bool, | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | /// Two-factor authentication verification page (login flow). | |
| 129 | - | #[derive(Template)] | |
| 130 | - | #[template(path = "pages/two_factor.html")] | |
| 131 | - | pub struct TwoFactorTemplate { | |
| 132 | - | pub csrf_token: CsrfTokenOption, | |
| 133 | - | pub session_user: Option<SessionUser>, | |
| 134 | - | pub error: Option<String>, | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | /// OAuth2 authorization / consent page. | |
| 138 | - | #[derive(Template)] | |
| 139 | - | #[template(path = "pages/oauth_authorize.html")] | |
| 140 | - | pub struct OAuthAuthorizeTemplate { | |
| 141 | - | pub csrf_token: CsrfTokenOption, | |
| 142 | - | pub session_user: Option<SessionUser>, | |
| 143 | - | pub app_name: String, | |
| 144 | - | pub client_id: String, | |
| 145 | - | pub redirect_uri: String, | |
| 146 | - | pub state: String, | |
| 147 | - | pub code_challenge: String, | |
| 148 | - | pub code_challenge_method: String, | |
| 149 | - | pub error_message: Option<String>, | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | /// Forgot password form. | |
| 153 | - | #[derive(Template)] | |
| 154 | - | #[template(path = "pages/forgot_password.html")] | |
| 155 | - | pub struct ForgotPasswordTemplate { | |
| 156 | - | pub csrf_token: CsrfTokenOption, | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | /// Password reset form (reached via email link). | |
| 160 | - | #[derive(Template)] | |
| 161 | - | #[template(path = "pages/reset_password.html")] | |
| 162 | - | pub struct ResetPasswordTemplate { | |
| 163 | - | pub csrf_token: CsrfTokenOption, | |
| 164 | - | pub valid: bool, | |
| 165 | - | pub user_id: String, | |
| 166 | - | pub expires: String, | |
| 167 | - | pub sig: String, | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | /// Public user profile page. | |
| 171 | - | #[derive(Template)] | |
| 172 | - | #[template(path = "pages/user.html")] | |
| 173 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 174 | - | pub struct UserTemplate { | |
| 175 | - | pub csrf_token: CsrfTokenOption, | |
| 176 | - | pub session_user: Option<SessionUser>, | |
| 177 | - | pub user: User, | |
| 178 | - | pub custom_links: Vec<CustomLink>, | |
| 179 | - | pub projects: Vec<Project>, | |
| 180 | - | pub public_collections: Vec<Collection>, | |
| 181 | - | /// User ID for the follow button target. | |
| 182 | - | pub user_id: String, | |
| 183 | - | /// Whether the current viewer is looking at their own profile. | |
| 184 | - | pub is_own_profile: bool, | |
| 185 | - | /// Whether the current viewer is following this user. | |
| 186 | - | pub is_following: bool, | |
| 187 | - | /// Total follower count for this user. | |
| 188 | - | pub follower_count: i64, | |
| 189 | - | /// Base URL for OG meta tags. | |
| 190 | - | pub host_url: Arc<str>, | |
| 191 | - | /// Whether this creator has voluntarily paused their account. | |
| 192 | - | pub creator_paused: bool, | |
| 193 | - | /// Whether this creator accepts tips. | |
| 194 | - | pub tips_enabled: bool, | |
| 195 | - | /// Creator's user ID for tip checkout (string for template use). | |
| 196 | - | pub creator_id: String, | |
| 197 | - | /// Project ID for tip attribution (None on user profile pages). | |
| 198 | - | pub tip_project_id: Option<String>, | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | /// Public collection page (shareable URL). | |
| 202 | - | #[derive(Template)] | |
| 203 | - | #[template(path = "pages/collection.html")] | |
| 204 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 205 | - | pub struct CollectionTemplate { | |
| 206 | - | pub csrf_token: CsrfTokenOption, | |
| 207 | - | pub session_user: Option<SessionUser>, | |
| 208 | - | pub collection: Collection, | |
| 209 | - | pub items: Vec<CollectionItem>, | |
| 210 | - | pub owner_username: String, | |
| 211 | - | pub owner_display_name: Option<String>, | |
| 212 | - | pub is_owner: bool, | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | /// Public project page with item listing. | |
| 216 | - | #[derive(Template)] | |
| 217 | - | #[template(path = "pages/project.html")] | |
| 218 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 219 | - | pub struct ProjectTemplate { | |
| 220 | - | pub csrf_token: CsrfTokenOption, | |
| 221 | - | pub session_user: Option<SessionUser>, | |
| 222 | - | pub project: Project, | |
| 223 | - | pub creator_username: String, | |
| 224 | - | pub items: Vec<Item>, | |
| 225 | - | /// Project ID for the follow button target. | |
| 226 | - | pub project_id: String, | |
| 227 | - | /// Whether the current viewer is following this project. | |
| 228 | - | pub is_following: bool, | |
| 229 | - | /// Total follower count for this project. | |
| 230 | - | pub follower_count: i64, | |
| 231 | - | /// Active subscription tiers available for this project. | |
| 232 | - | pub subscription_tiers: Vec<SubscriptionTier>, | |
| 233 | - | /// Whether the current viewer already has an active subscription. | |
| 234 | - | pub has_subscription: bool, | |
| 235 | - | /// Base URL for OG meta tags. | |
| 236 | - | pub host_url: Arc<str>, | |
| 237 | - | /// Linked git repositories: (name, URL) pairs. | |
| 238 | - | pub git_repos: Vec<(String, String)>, | |
| 239 | - | /// Whether this project has any published blog posts. | |
| 240 | - | pub has_blog_posts: bool, | |
| 241 | - | /// URL to the paired MT community forum (None if no community provisioned). | |
| 242 | - | pub community_url: Option<String>, | |
| 243 | - | /// Whether the project owner accepts tips. | |
| 244 | - | pub tips_enabled: bool, | |
| 245 | - | /// Creator's user ID for tip checkout (string for template use). | |
| 246 | - | pub creator_id: String, | |
| 247 | - | /// Project ID for tip attribution. | |
| 248 | - | pub tip_project_id: Option<String>, | |
| 249 | - | /// Whether the current viewer owns this project. | |
| 250 | - | pub is_owner: bool, | |
| 251 | - | /// Tabbed markdown sections (privacy, terms, FAQ, etc). | |
| 252 | - | pub sections: Vec<crate::types::ProjectSection>, | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | /// Project paywall landing page (shown when a project requires purchase/subscription). | |
| 256 | - | #[derive(Template)] | |
| 257 | - | #[template(path = "pages/project_paywall.html")] | |
| 258 | - | pub struct ProjectPaywallTemplate { | |
| 259 | - | pub csrf_token: CsrfTokenOption, | |
| 260 | - | pub session_user: Option<SessionUser>, | |
| 261 | - | pub project: Project, | |
| 262 | - | pub creator_username: String, | |
| 263 | - | /// Human-readable pricing (e.g. "$19.99", "Subscription"). | |
| 264 | - | pub price_display: String, | |
| 265 | - | /// What kind of checkout flow is needed. | |
| 266 | - | pub checkout_type: crate::pricing::CheckoutType, | |
| 267 | - | /// Available subscription tiers (for subscription-model projects). | |
| 268 | - | pub subscription_tiers: Vec<SubscriptionTier>, | |
| 269 | - | /// Base URL for OG meta tags. | |
| 270 | - | pub host_url: Arc<str>, | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | /// Public item detail page. | |
| 274 | - | #[derive(Template)] | |
| 275 | - | #[template(path = "pages/item.html")] | |
| 276 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 277 | - | pub struct ItemTemplate { | |
| 278 | - | pub csrf_token: CsrfTokenOption, | |
| 279 | - | pub session_user: Option<SessionUser>, | |
| 280 | - | pub item: Item, | |
| 281 | - | pub creator_username: String, | |
| 282 | - | pub project_title: String, | |
| 283 | - | pub project_slug: String, | |
| 284 | - | pub versions: Vec<Version>, | |
| 285 | - | /// Base URL for OG meta tags. | |
| 286 | - | pub host_url: Arc<str>, | |
| 287 | - | /// URL to the MT discussion thread (None if no linked thread or MT unavailable). | |
| 288 | - | pub discussion_url: Option<String>, | |
| 289 | - | /// Number of posts in the linked discussion thread. | |
| 290 | - | pub discussion_count: Option<i64>, | |
| 291 | - | /// Project cover image URL (fallback for og:image when item has no cover). | |
| 292 | - | pub project_cover_image_url: Option<String>, | |
| 293 | - | /// Child items for bundle-type items (empty for non-bundles). | |
| 294 | - | pub bundle_items: Vec<Item>, | |
| 295 | - | /// Bundles containing this item (for unlisted items, to show "Available in" links). | |
| 296 | - | pub containing_bundles: Vec<Item>, | |
| 297 | - | /// Tabbed content sections (e.g. Features, Installation, Specs). | |
| 298 | - | pub sections: Vec<ItemSection>, | |
| 299 | - | /// Whether the current user is the item's creator (for dashboard links). | |
| 300 | - | pub is_owner: bool, | |
| 301 | - | /// Whether the current user has wishlisted this item. | |
| 302 | - | pub is_wishlisted: bool, | |
| 303 | - | /// Whether the current user has this item in their cart. | |
| 304 | - | pub in_cart: bool, | |
| 305 | - | /// How many of the current user's collections contain this item. | |
| 306 | - | pub collection_count: u32, | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | /// Blog/article reader view. | |
| 310 | - | #[derive(Template)] | |
| 311 | - | #[template(path = "pages/text_reader.html")] | |
| 312 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 313 | - | pub struct TextReaderTemplate { | |
| 314 | - | pub csrf_token: CsrfTokenOption, | |
| 315 | - | pub session_user: Option<SessionUser>, | |
| 316 | - | pub item: Item, | |
| 317 | - | pub creator_username: String, | |
| 318 | - | pub creator_display_name: Option<String>, | |
| 319 | - | /// First-letter initials for the avatar circle (e.g. "JD" for "Jane Doe"). | |
| 320 | - | pub creator_avatar_initials: String, | |
| 321 | - | pub project_title: String, | |
| 322 | - | pub project_slug: String, | |
| 323 | - | /// Whether the item has a zero price (free content, no purchase required). | |
| 324 | - | pub is_free: bool, | |
| 325 | - | /// Whether the current user already has this item in their library. | |
| 326 | - | pub in_library: bool, | |
| 327 | - | /// Whether the current user can view the full content (purchased, free, or is the creator). | |
| 328 | - | pub has_access: bool, | |
| 329 | - | /// Base URL for OG meta tags. | |
| 330 | - | pub host_url: Arc<str>, | |
| 331 | - | /// URL to the MT discussion thread (None if no linked thread or MT unavailable). | |
| 332 | - | pub discussion_url: Option<String>, | |
| 333 | - | /// Number of posts in the linked discussion thread. | |
| 334 | - | pub discussion_count: Option<i64>, | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | /// Audio streaming player view. | |
| 338 | - | #[derive(Template)] | |
| 339 | - | #[template(path = "pages/audio_player.html")] | |
| 340 | - | pub struct AudioPlayerTemplate { | |
| 341 | - | pub csrf_token: CsrfTokenOption, | |
| 342 | - | pub session_user: Option<SessionUser>, | |
| 343 | - | pub item: Item, | |
| 344 | - | pub creator_username: String, | |
| 345 | - | pub creator_display_name: Option<String>, | |
| 346 | - | /// First-letter initials for the avatar circle. | |
| 347 | - | pub creator_avatar_initials: String, | |
| 348 | - | pub project_title: Option<String>, | |
| 349 | - | pub project_slug: String, | |
| 350 | - | /// Pre-signed S3 URL for the audio file; `None` if no audio uploaded yet. | |
| 351 | - | pub audio_url: Option<String>, | |
| 352 | - | /// Timestamp-based chapter markers for the player seek bar. | |
| 353 | - | pub chapters: Vec<Chapter>, | |
| 354 | - | /// Whether the item has a zero price. | |
| 355 | - | pub is_free: bool, | |
| 356 | - | /// Whether the current user already has this item in their library. | |
| 357 | - | pub in_library: bool, | |
| 358 | - | /// Whether the current user can stream the audio (purchased, free, or is the creator). | |
| 359 | - | pub has_access: bool, | |
| 360 | - | /// JSON-encoded segment list for insertion playback. Empty string means no insertions. | |
| 361 | - | pub segments_json: String, | |
| 362 | - | /// Base URL for OG meta tags. | |
| 363 | - | pub host_url: Arc<str>, | |
| 364 | - | /// URL to the MT discussion thread (None if no linked thread or MT unavailable). | |
| 365 | - | pub discussion_url: Option<String>, | |
| 366 | - | /// Number of posts in the linked discussion thread. | |
| 367 | - | pub discussion_count: Option<i64>, | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | /// Video player page with custom controls, insertions, chapters. | |
| 371 | - | #[derive(Template)] | |
| 372 | - | #[template(path = "pages/video_player.html")] | |
| 373 | - | pub struct VideoPlayerTemplate { | |
| 374 | - | pub csrf_token: CsrfTokenOption, | |
| 375 | - | pub session_user: Option<SessionUser>, | |
| 376 | - | pub item: Item, | |
| 377 | - | pub creator_username: String, | |
| 378 | - | pub creator_display_name: Option<String>, | |
| 379 | - | pub creator_avatar_initials: String, | |
| 380 | - | pub project_title: Option<String>, | |
| 381 | - | pub project_slug: String, | |
| 382 | - | /// Pre-signed S3 URL for the video file. | |
| 383 | - | pub video_url: Option<String>, | |
| 384 | - | pub chapters: Vec<Chapter>, | |
| 385 | - | pub is_free: bool, | |
| 386 | - | pub in_library: bool, | |
| 387 | - | pub has_access: bool, | |
| 388 | - | pub segments_json: String, | |
| 389 | - | pub host_url: Arc<str>, | |
| 390 | - | pub discussion_url: Option<String>, | |
| 391 | - | pub discussion_count: Option<i64>, | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | /// Browse/discover page with filtering and pagination. | |
| 395 | - | #[derive(Template)] | |
| 396 | - | #[template(path = "pages/discover.html")] | |
| 397 | - | pub struct DiscoverTemplate { | |
| 398 | - | pub csrf_token: CsrfTokenOption, | |
| 399 | - | pub session_user: Option<SessionUser>, | |
| 400 | - | pub items: Vec<DiscoverItem>, | |
| 401 | - | pub projects: Vec<DiscoverProject>, | |
| 402 | - | /// Active browse mode: `"items"` or `"projects"`. | |
| 403 | - | pub mode: String, | |
| 404 | - | /// Item type facets for sidebar (e.g. text, audio, download). | |
| 405 | - | pub type_filters: Vec<FilterCategory>, | |
| 406 | - | /// Tag facets for sidebar (top tags by count). | |
| 407 | - | pub tag_filters: Vec<FilterCategory>, | |
| 408 | - | /// Project category facets for sidebar (projects mode only). | |
| 409 | - | pub category_filters: Vec<FilterCategory>, | |
| 410 | - | pub price_filters: Vec<PriceFilter>, | |
| 411 | - | pub total_items: u32, | |
| 412 | - | pub current_page: u32, | |
| 413 | - | pub total_pages: u32, | |
| 414 | - | pub search_query: String, | |
| 415 | - | /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`). | |
| 416 | - | pub sort_by: String, | |
| 417 | - | /// Currently selected item_type filter, or empty for "All". | |
| 418 | - | pub current_type: String, | |
| 419 | - | /// Currently selected tag slug filter, or empty for "All". | |
| 420 | - | pub current_tag: String, | |
| 421 | - | /// Currently selected category slug filter, or empty for "All". | |
| 422 | - | pub current_category: String, | |
| 423 | - | /// Page numbers to render in the pagination bar. | |
| 424 | - | pub pagination_range: Vec<u32>, | |
| 425 | - | pub showing_start: u32, | |
| 426 | - | pub showing_end: u32, | |
| 427 | - | /// AI tier facets for sidebar (items mode only). | |
| 428 | - | pub ai_tier_filters: Vec<FilterCategory>, | |
| 429 | - | /// Currently selected AI tier filter slug, or empty for "All". | |
| 430 | - | pub current_ai_tier: String, | |
| 431 | - | /// Whether the "has source code" filter is active (projects mode). | |
| 432 | - | pub has_source: bool, | |
| 433 | - | /// Number of active filters (for mobile filter toggle badge). | |
| 434 | - | pub active_filter_count: u32, | |
| 435 | - | /// Whether the current user is authenticated (for collection save buttons in results). | |
| 436 | - | pub is_authenticated: bool, | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | /// Tag tree browser with breadcrumb navigation. | |
| 440 | - | #[derive(Template)] | |
| 441 | - | #[template(path = "pages/tag_tree.html")] | |
| 442 | - | pub struct TagTreeTemplate { | |
| 443 | - | pub csrf_token: CsrfTokenOption, | |
| 444 | - | pub session_user: Option<SessionUser>, | |
| 445 | - | pub categories: Vec<TagTreeNode>, | |
| 446 | - | pub breadcrumbs: Vec<TagBreadcrumb>, | |
| 447 | - | pub current_tag: Option<TagBreadcrumb>, | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | /// Purchase confirmation page showing fee breakdown. | |
| 451 | - | #[derive(Template)] | |
| 452 | - | #[template(path = "pages/purchase.html")] | |
| 453 | - | pub struct PurchaseTemplate { | |
| 454 | - | pub csrf_token: CsrfTokenOption, | |
| 455 | - | pub item: Item, | |
| 456 | - | pub creator_username: String, | |
| 457 | - | pub stripe_fee: String, | |
| 458 | - | pub creator_receives: String, | |
| 459 | - | /// Pre-filled promo code from `?code=` query parameter. | |
| 460 | - | pub promo_code: String, | |
| 461 | - | /// Whether PWYW pricing is enabled for this item. | |
| 462 | - | pub pwyw_enabled: bool, | |
| 463 | - | /// Minimum price in cents when PWYW is enabled. | |
| 464 | - | pub pwyw_min_cents: i32, | |
| 465 | - | /// Formatted suggested price in dollars (e.g. "9.99"). | |
| 466 | - | pub suggested_price: String, | |
| 467 | - | /// Formatted minimum price in dollars (e.g. "1.00"). | |
| 468 | - | pub pwyw_min_dollars: String, | |
| 469 | - | /// Whether the creator has Stripe Tax enabled. | |
| 470 | - | pub stripe_tax_enabled: bool, | |
| 471 | - | /// Whether the current visitor is logged in (show guest checkout if not). | |
| 472 | - | pub is_logged_in: bool, | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | /// Minimal direct purchase page — no navigation, for link-in-bio sharing. | |
| 476 | - | #[derive(Template)] | |
| 477 | - | #[template(path = "pages/buy.html")] | |
| 478 | - | pub struct BuyPageTemplate { | |
| 479 | - | pub item: Item, | |
| 480 | - | pub creator_username: String, | |
| 481 | - | pub creator_display_name: Option<String>, | |
| 482 | - | pub pwyw_enabled: bool, | |
| 483 | - | pub pwyw_min_dollars: String, | |
| 484 | - | pub suggested_price: String, | |
| 485 | - | pub host_url: Arc<str>, | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | /// Feed page showing items from followed users, projects, and tags. | |
| 489 | - | #[derive(Template)] | |
| 490 | - | #[template(path = "pages/feed.html")] | |
| 491 | - | pub struct FeedTemplate { | |
| 492 | - | pub csrf_token: CsrfTokenOption, | |
| 493 | - | pub session_user: Option<SessionUser>, | |
| 494 | - | pub items: Vec<DiscoverItem>, | |
| 495 | - | pub total_items: u32, | |
| 496 | - | pub current_page: u32, | |
| 497 | - | pub total_pages: u32, | |
| 498 | - | pub pagination_range: Vec<u32>, | |
| 499 | - | pub showing_start: u32, | |
| 500 | - | pub showing_end: u32, |
Lines truncated
| @@ -0,0 +1,250 @@ | |||
| 1 | + | //! Templates for the public git source browser, issues, and repo settings. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use askama::Template; | |
| 6 | + | ||
| 7 | + | use crate::auth::SessionUser; | |
| 8 | + | use crate::git; | |
| 9 | + | use crate::types::*; | |
| 10 | + | ||
| 11 | + | use super::super::CsrfTokenOption; | |
| 12 | + | ||
| 13 | + | /// An item paired with its versions, for release display on the git repo page. | |
| 14 | + | pub struct ReleaseItem { | |
| 15 | + | pub item: Item, | |
| 16 | + | pub versions: Vec<Version>, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// Repository overview: file tree at HEAD + README. | |
| 20 | + | #[derive(Template)] | |
| 21 | + | #[template(path = "pages/git/repo.html")] | |
| 22 | + | pub struct GitRepoTemplate { | |
| 23 | + | pub csrf_token: CsrfTokenOption, | |
| 24 | + | pub session_user: Option<SessionUser>, | |
| 25 | + | pub owner: String, | |
| 26 | + | pub repo_name: String, | |
| 27 | + | pub description: Option<String>, | |
| 28 | + | pub current_ref: String, | |
| 29 | + | pub refs: Vec<git::RefInfo>, | |
| 30 | + | pub tree_items: Vec<git::TreeItem>, | |
| 31 | + | pub readme_html: Option<String>, | |
| 32 | + | pub host_url: Arc<str>, | |
| 33 | + | /// Hostname for SSH clone URLs (e.g., "git.makenot.work"). Hidden when `None`. | |
| 34 | + | pub git_ssh_host: Option<String>, | |
| 35 | + | /// Linked project, if this repo is associated with a public project. | |
| 36 | + | pub linked_project: Option<Project>, | |
| 37 | + | /// Public items with versions from the linked project (releases). | |
| 38 | + | pub release_items: Vec<ReleaseItem>, | |
| 39 | + | /// Number of open issues for the nav bar badge. | |
| 40 | + | pub open_issue_count: i64, | |
| 41 | + | /// Whether the current viewer is the repo owner (for settings link). | |
| 42 | + | pub is_owner: bool, | |
| 43 | + | pub active_tab: &'static str, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Subdirectory listing with breadcrumb navigation. | |
| 47 | + | #[derive(Template)] | |
| 48 | + | #[template(path = "pages/git/tree.html")] | |
| 49 | + | pub struct GitTreeTemplate { | |
| 50 | + | pub csrf_token: CsrfTokenOption, | |
| 51 | + | pub session_user: Option<SessionUser>, | |
| 52 | + | pub owner: String, | |
| 53 | + | pub repo_name: String, | |
| 54 | + | pub current_ref: String, | |
| 55 | + | pub refs: Vec<git::RefInfo>, | |
| 56 | + | pub path: String, | |
| 57 | + | pub parent_path: String, | |
| 58 | + | /// Whether we're in a subdirectory (for ".." link and path joining). | |
| 59 | + | pub in_subdir: bool, | |
| 60 | + | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 61 | + | pub tree_items: Vec<git::TreeItem>, | |
| 62 | + | pub open_issue_count: i64, | |
| 63 | + | pub is_owner: bool, | |
| 64 | + | pub active_tab: &'static str, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// File viewer with syntax highlighting and line numbers. | |
| 68 | + | #[derive(Template)] | |
| 69 | + | #[template(path = "pages/git/file.html")] | |
| 70 | + | pub struct GitFileTemplate { | |
| 71 | + | pub csrf_token: CsrfTokenOption, | |
| 72 | + | pub session_user: Option<SessionUser>, | |
| 73 | + | pub owner: String, | |
| 74 | + | pub repo_name: String, | |
| 75 | + | pub current_ref: String, | |
| 76 | + | pub refs: Vec<git::RefInfo>, | |
| 77 | + | pub file_path: String, | |
| 78 | + | pub filename: String, | |
| 79 | + | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 80 | + | pub file_size: String, | |
| 81 | + | pub line_count: usize, | |
| 82 | + | pub is_binary: bool, | |
| 83 | + | pub highlighted_lines: Vec<String>, | |
| 84 | + | pub open_issue_count: i64, | |
| 85 | + | pub is_owner: bool, | |
| 86 | + | pub active_tab: &'static str, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Commit log with pagination. | |
| 90 | + | #[derive(Template)] | |
| 91 | + | #[template(path = "pages/git/commits.html")] | |
| 92 | + | pub struct GitCommitsTemplate { | |
| 93 | + | pub csrf_token: CsrfTokenOption, | |
| 94 | + | pub session_user: Option<SessionUser>, | |
| 95 | + | pub owner: String, | |
| 96 | + | pub repo_name: String, | |
| 97 | + | pub current_ref: String, | |
| 98 | + | pub refs: Vec<git::RefInfo>, | |
| 99 | + | pub commits: Vec<git::CommitInfo>, | |
| 100 | + | pub page: usize, | |
| 101 | + | pub has_more: bool, | |
| 102 | + | pub open_issue_count: i64, | |
| 103 | + | /// Whether the current viewer is the repo owner (for settings link). | |
| 104 | + | pub is_owner: bool, | |
| 105 | + | pub active_tab: &'static str, | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Commit detail page with inline diffs. | |
| 109 | + | #[derive(Template)] | |
| 110 | + | #[template(path = "pages/git/commit.html")] | |
| 111 | + | pub struct GitCommitDetailTemplate { | |
| 112 | + | pub csrf_token: CsrfTokenOption, | |
| 113 | + | pub session_user: Option<SessionUser>, | |
| 114 | + | pub owner: String, | |
| 115 | + | pub repo_name: String, | |
| 116 | + | pub current_ref: String, | |
| 117 | + | pub refs: Vec<git::RefInfo>, | |
| 118 | + | pub detail: git::CommitDetail, | |
| 119 | + | pub diff_files: Vec<git::DiffFile>, | |
| 120 | + | pub total_files: usize, | |
| 121 | + | pub total_additions: usize, | |
| 122 | + | pub total_deletions: usize, | |
| 123 | + | pub open_issue_count: i64, | |
| 124 | + | pub is_owner: bool, | |
| 125 | + | pub active_tab: &'static str, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// Blame view for a single file. | |
| 129 | + | #[derive(Template)] | |
| 130 | + | #[template(path = "pages/git/blame.html")] | |
| 131 | + | pub struct GitBlameTemplate { | |
| 132 | + | pub csrf_token: CsrfTokenOption, | |
| 133 | + | pub session_user: Option<SessionUser>, | |
| 134 | + | pub owner: String, | |
| 135 | + | pub repo_name: String, | |
| 136 | + | pub current_ref: String, | |
| 137 | + | pub refs: Vec<git::RefInfo>, | |
| 138 | + | pub file_path: String, | |
| 139 | + | pub filename: String, | |
| 140 | + | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 141 | + | pub blame_lines: Vec<git::BlameLine>, | |
| 142 | + | pub open_issue_count: i64, | |
| 143 | + | pub is_owner: bool, | |
| 144 | + | pub active_tab: &'static str, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// User's repository listing page. | |
| 148 | + | #[derive(Template)] | |
| 149 | + | #[template(path = "pages/git/repos.html")] | |
| 150 | + | pub struct GitUserReposTemplate { | |
| 151 | + | pub csrf_token: CsrfTokenOption, | |
| 152 | + | pub session_user: Option<SessionUser>, | |
| 153 | + | pub owner: String, | |
| 154 | + | pub repos: Vec<crate::db::DbGitRepo>, | |
| 155 | + | pub is_owner: bool, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Public explore page listing all public repos across all users. | |
| 159 | + | #[derive(Template)] | |
| 160 | + | #[template(path = "pages/git/explore.html")] | |
| 161 | + | pub struct GitExploreTemplate { | |
| 162 | + | pub csrf_token: CsrfTokenOption, | |
| 163 | + | pub session_user: Option<SessionUser>, | |
| 164 | + | pub repos: Vec<crate::db::git_repos::PublicRepoWithOwner>, | |
| 165 | + | pub page: usize, | |
| 166 | + | pub has_more: bool, | |
| 167 | + | pub total_count: i64, | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Per-file commit history with breadcrumb context. | |
| 171 | + | #[derive(Template)] | |
| 172 | + | #[template(path = "pages/git/file_log.html")] | |
| 173 | + | pub struct GitFileLogTemplate { | |
| 174 | + | pub csrf_token: CsrfTokenOption, | |
| 175 | + | pub session_user: Option<SessionUser>, | |
| 176 | + | pub owner: String, | |
| 177 | + | pub repo_name: String, | |
| 178 | + | pub current_ref: String, | |
| 179 | + | pub refs: Vec<git::RefInfo>, | |
| 180 | + | pub file_path: String, | |
| 181 | + | pub filename: String, | |
| 182 | + | pub breadcrumbs: Vec<git::Breadcrumb>, | |
| 183 | + | pub commits: Vec<git::CommitInfo>, | |
| 184 | + | pub page: usize, | |
| 185 | + | pub has_more: bool, | |
| 186 | + | pub open_issue_count: i64, | |
| 187 | + | pub is_owner: bool, | |
| 188 | + | pub active_tab: &'static str, | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // ============================================================================ | |
| 192 | + | // Git Issues | |
| 193 | + | // ============================================================================ | |
| 194 | + | ||
| 195 | + | /// Issue list page with status tabs and search (read-only). | |
| 196 | + | #[derive(Template)] | |
| 197 | + | #[template(path = "pages/git/issues.html")] | |
| 198 | + | pub struct GitIssueListTemplate { | |
| 199 | + | pub csrf_token: CsrfTokenOption, | |
| 200 | + | pub session_user: Option<SessionUser>, | |
| 201 | + | pub owner: String, | |
| 202 | + | pub repo_name: String, | |
| 203 | + | pub current_ref: String, | |
| 204 | + | pub issues: Vec<crate::db::DbIssueWithMeta>, | |
| 205 | + | pub open_count: i64, | |
| 206 | + | pub closed_count: i64, | |
| 207 | + | pub current_status: String, | |
| 208 | + | pub search_query: String, | |
| 209 | + | pub current_page: i64, | |
| 210 | + | pub total_pages: i64, | |
| 211 | + | /// Whether the current viewer is the repo owner (for settings link). | |
| 212 | + | pub is_owner: bool, | |
| 213 | + | /// Email address for submitting new issues. | |
| 214 | + | pub email_address: String, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// Issue detail page with comments (read-only). | |
| 218 | + | #[derive(Template)] | |
| 219 | + | #[template(path = "pages/git/issue.html")] | |
| 220 | + | pub struct GitIssueDetailTemplate { | |
| 221 | + | pub csrf_token: CsrfTokenOption, | |
| 222 | + | pub session_user: Option<SessionUser>, | |
| 223 | + | pub owner: String, | |
| 224 | + | pub repo_name: String, | |
| 225 | + | pub current_ref: String, | |
| 226 | + | pub issue: crate::db::DbIssue, | |
| 227 | + | pub author_username: String, | |
| 228 | + | pub comments: Vec<crate::db::DbIssueCommentWithAuthor>, | |
| 229 | + | pub is_owner: bool, | |
| 230 | + | pub open_issue_count: i64, | |
| 231 | + | /// Email address for submitting new issues. | |
| 232 | + | pub email_address: String, | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Repository settings page (owner only). | |
| 236 | + | #[derive(Template)] | |
| 237 | + | #[template(path = "pages/git/settings.html")] | |
| 238 | + | pub struct GitRepoSettingsTemplate { | |
| 239 | + | pub csrf_token: CsrfTokenOption, | |
| 240 | + | pub session_user: Option<SessionUser>, | |
| 241 | + | pub owner: String, | |
| 242 | + | pub repo_name: String, | |
| 243 | + | pub current_ref: String, | |
| 244 | + | pub repo: crate::db::DbGitRepo, | |
| 245 | + | pub open_issue_count: i64, | |
| 246 | + | /// Owner's projects for the link dropdown. | |
| 247 | + | pub projects: Vec<crate::db::DbProject>, | |
| 248 | + | /// ID of the currently linked project as a string (for dropdown comparison). | |
| 249 | + | pub linked_project_id: String, | |
| 250 | + | } |