| 1 | //! Intake is independent of account closure, workspace ownership and billing. |
| 2 | //! Staff assess shared content and retention; an event publish is not proof of erasure. |
| 3 | use crate::{Identity, security::is_person}; |
| 4 | use g1t_contracts::identity::UserArgs; |
| 5 | use g1t_contracts::time::rfc3339; |
| 6 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; |
| 7 | use g1t_kit::now_ms; |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | use worker::Result; |
| 10 | |
| 11 | #[derive(Deserialize)] |
| 12 | pub(crate) struct RequestArgs { |
| 13 | user: User, |
| 14 | } |
| 15 | #[derive(Deserialize)] |
| 16 | pub(crate) struct ReviewArgs { |
| 17 | id: String, |
| 18 | staff: String, |
| 19 | status: String, |
| 20 | evidence: String, |
| 21 | } |
| 22 | #[derive(Deserialize)] |
| 23 | pub(crate) struct IntakeArgs { |
| 24 | username: String, |
| 25 | received_at: String, |
| 26 | staff: String, |
| 27 | evidence: String, |
| 28 | } |
| 29 | #[derive(Deserialize, Serialize)] |
| 30 | pub(crate) struct ErasureRequest { |
| 31 | id: String, |
| 32 | user_id: String, |
| 33 | username: String, |
| 34 | requested_at: String, |
| 35 | due_at: String, |
| 36 | status: String, |
| 37 | scope: String, |
| 38 | evidence: String, |
| 39 | updated_at: String, |
| 40 | updated_by: Option<String>, |
| 41 | closed_at: Option<String>, |
| 42 | /// The case's trail, oldest first: intake notes, reviews, refusal or completion. |
| 43 | #[serde(default)] |
| 44 | reviews: Vec<Review>, |
| 45 | } |
| 46 | #[derive(Deserialize, Serialize)] |
| 47 | pub(crate) struct Review { |
| 48 | #[serde(default, skip_serializing)] |
| 49 | request_id: String, |
| 50 | at: String, |
| 51 | staff: String, |
| 52 | status: String, |
| 53 | evidence: String, |
| 54 | } |
| 55 | |
| 56 | /// Same day next month, clamped to its last day; never a fixed 30-day delay. |
| 57 | fn deadline(now: &str) -> String { |
| 58 | let mut year: u32 = now[0..4].parse().unwrap(); |
| 59 | let month: u32 = now[5..7].parse().unwrap(); |
| 60 | let day: u32 = now[8..10].parse().unwrap(); |
| 61 | let month = if month == 12 { |
| 62 | year += 1; |
| 63 | 1 |
| 64 | } else { |
| 65 | month + 1 |
| 66 | }; |
| 67 | let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); |
| 68 | let days = match month { |
| 69 | 2 => { |
| 70 | if leap { |
| 71 | 29 |
| 72 | } else { |
| 73 | 28 |
| 74 | } |
| 75 | } |
| 76 | 4 | 6 | 9 | 11 => 30, |
| 77 | _ => 31, |
| 78 | }; |
| 79 | format!("{year:04}-{month:02}-{:02}{}", day.min(days), &now[10..]) |
| 80 | } |
| 81 | |
| 82 | impl Identity { |
| 83 | /// Requests received by email or verbally keep their original receipt date. |
| 84 | pub(crate) async fn intake_erasure(&self, a: IntakeArgs) -> Result<Outcome<bool>> { |
| 85 | let now = rfc3339(now_ms()); |
| 86 | let received = g1t_contracts::time::parse_rfc3339(&a.received_at).map(rfc3339); |
| 87 | let Some(received) = received.filter(|at| at <= &now && at == &a.received_at) else { |
| 88 | return Ok(Outcome::fail( |
| 89 | FailureCode::Invalid, |
| 90 | "Use the original receipt time in UTC (YYYY-MM-DDTHH:MM:SS.sssZ), no later than now.", |
| 91 | )); |
| 92 | }; |
| 93 | if a.staff.trim().is_empty() || a.evidence.trim().is_empty() || a.evidence.len() > 16000 { |
| 94 | return Ok(Outcome::fail( |
| 95 | FailureCode::Invalid, |
| 96 | "Record the source of the request and the identity verification needed or performed.", |
| 97 | )); |
| 98 | } |
| 99 | #[derive(Deserialize)] |
| 100 | struct Person { |
| 101 | id: String, |
| 102 | username: String, |
| 103 | } |
| 104 | let Some(person) = self.db.prepare("SELECT id, username FROM users WHERE username = ?1 UNION ALL SELECT user_id AS id, username FROM deleted_users WHERE username = ?1 LIMIT 1") |
| 105 | .bind(&[a.username.trim().to_lowercase().into()])?.first::<Person>(None).await? else { |
| 106 | return Ok(Outcome::fail(FailureCode::NotFound, "Account not found. Track requests about non-account data in the restricted support case system.")); |
| 107 | }; |
| 108 | // Earlier evidence of receipt moves the deadline earlier, never later. |
| 109 | // The intake notes join the open case's review trail either way, so a |
| 110 | // second channel's evidence is never dropped by the upsert. |
| 111 | let id = new_id("era", now_ms()); |
| 112 | self.db.batch(vec![ |
| 113 | self.db.prepare("INSERT INTO erasure_requests (id, user_id, username, requested_at, due_at, scope, evidence, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, 'Personal data and account; assess shared content and lawful retention separately', ?, ?, ?) ON CONFLICT(user_id) WHERE closed_at IS NULL DO UPDATE SET requested_at = min(requested_at, excluded.requested_at), due_at = min(due_at, excluded.due_at)") |
| 114 | .bind(&[id.as_str().into(), person.id.as_str().into(), person.username.into(), received.as_str().into(), deadline(&received).into(), a.evidence.as_str().into(), now.as_str().into(), a.staff.as_str().into()])?, |
| 115 | self.db.prepare("INSERT INTO erasure_reviews (request_id, at, staff, status, evidence) SELECT id, ?2, ?3, 'intake', ?4 FROM erasure_requests WHERE user_id = ?1 AND closed_at IS NULL") |
| 116 | .bind(&[person.id.into(), now.into(), a.staff.into(), format!("Received {received}. {}", a.evidence).into()])?, |
| 117 | ]).await?; |
| 118 | Ok(Outcome::Ok(true)) |
| 119 | } |
| 120 | |
| 121 | pub(crate) async fn request_erasure( |
| 122 | &self, |
| 123 | a: RequestArgs, |
| 124 | ) -> Result<Outcome<serde_json::Value>> { |
| 125 | if !is_person(&a.user) { |
| 126 | return Ok(Outcome::fail( |
| 127 | FailureCode::Forbidden, |
| 128 | "Sign in as yourself to request erasure.", |
| 129 | )); |
| 130 | } |
| 131 | #[derive(Deserialize)] |
| 132 | struct Person { |
| 133 | username: String, |
| 134 | } |
| 135 | let Some(person) = self |
| 136 | .db |
| 137 | .prepare("SELECT username FROM users WHERE id = ? AND deleted_at IS NULL") |
| 138 | .bind(&[a.user.id.as_str().into()])? |
| 139 | .first::<Person>(None) |
| 140 | .await? |
| 141 | else { |
| 142 | return Ok(Outcome::fail(FailureCode::NotFound, "Account not found.")); |
| 143 | }; |
| 144 | let now = rfc3339(now_ms()); |
| 145 | let due = deadline(&now); |
| 146 | self.db.prepare("INSERT OR IGNORE INTO erasure_requests (id, user_id, username, requested_at, due_at, scope, updated_at) VALUES (?, ?, ?, ?, ?, 'Personal data and account; assess shared content and lawful retention separately', ?)") |
| 147 | .bind(&[new_id("era", now_ms()).into(), a.user.id.as_str().into(), person.username.into(), now.as_str().into(), due.into(), now.into()])?.run().await?; |
| 148 | let row = self |
| 149 | .db |
| 150 | .prepare("SELECT * FROM erasure_requests WHERE user_id = ? AND closed_at IS NULL") |
| 151 | .bind(&[a.user.id.into()])? |
| 152 | .first::<ErasureRequest>(None) |
| 153 | .await? |
| 154 | .ok_or_else(|| worker::Error::RustError("Request not recorded".into()))?; |
| 155 | Ok(Outcome::Ok( |
| 156 | serde_json::json!({"id": row.id, "requested_at": row.requested_at, "due_at": row.due_at, "status": row.status}), |
| 157 | )) |
| 158 | } |
| 159 | |
| 160 | pub(crate) async fn my_erasure(&self, a: UserArgs) -> Result<Option<serde_json::Value>> { |
| 161 | if !is_person(&a.user) { |
| 162 | return Ok(None); |
| 163 | } |
| 164 | // Internal notes and staff details are not part of the self-service status. |
| 165 | self.db.prepare("SELECT id, requested_at, due_at, status FROM erasure_requests WHERE user_id = ? ORDER BY requested_at DESC LIMIT 1") |
| 166 | .bind(&[a.user.id.into()])?.first(None).await |
| 167 | } |
| 168 | |
| 169 | pub(crate) async fn erasure_requests(&self) -> Result<Vec<ErasureRequest>> { |
| 170 | const LISTED: &str = "SELECT id FROM erasure_requests ORDER BY closed_at IS NOT NULL, due_at LIMIT 500"; |
| 171 | let mut requests = self.db.prepare(format!("SELECT * FROM erasure_requests WHERE id IN ({LISTED}) ORDER BY closed_at IS NOT NULL, due_at")).all().await?.results::<ErasureRequest>()?; |
| 172 | let reviews = self.db.prepare(format!("SELECT request_id, at, staff, status, evidence FROM erasure_reviews WHERE request_id IN ({LISTED}) ORDER BY id")).all().await?.results::<Review>()?; |
| 173 | for review in reviews { |
| 174 | if let Some(request) = requests.iter_mut().find(|r| r.id == review.request_id) { |
| 175 | request.reviews.push(review); |
| 176 | } |
| 177 | } |
| 178 | Ok(requests) |
| 179 | } |
| 180 | |
| 181 | pub(crate) async fn review_erasure(&self, a: ReviewArgs) -> Result<Outcome<bool>> { |
| 182 | if a.staff.trim().is_empty() |
| 183 | || !["reviewing", "completed", "refused"].contains(&a.status.as_str()) |
| 184 | || a.evidence.trim().is_empty() |
| 185 | || a.evidence.len() > 16000 |
| 186 | { |
| 187 | return Ok(Outcome::fail( |
| 188 | FailureCode::Invalid, |
| 189 | "Choose a status and record the review evidence (up to 16,000 bytes).", |
| 190 | )); |
| 191 | } |
| 192 | let Some(case) = self |
| 193 | .db |
| 194 | .prepare("SELECT * FROM erasure_requests WHERE id = ? AND closed_at IS NULL") |
| 195 | .bind(&[a.id.as_str().into()])? |
| 196 | .first::<ErasureRequest>(None) |
| 197 | .await? |
| 198 | else { |
| 199 | return Ok(Outcome::fail( |
| 200 | FailureCode::NotFound, |
| 201 | "Open request not found.", |
| 202 | )); |
| 203 | }; |
| 204 | if a.status == "completed" { |
| 205 | let pending = self.db.prepare("SELECT 1 FROM users WHERE id = ?1 UNION ALL SELECT 1 FROM purge_jobs WHERE subject_id = ?1 AND completed_at IS NULL LIMIT 1") |
| 206 | .bind(&[case.user_id.as_str().into()])?.first::<serde_json::Value>(None).await?.is_some(); |
| 207 | if pending { |
| 208 | return Ok(Outcome::fail( |
| 209 | FailureCode::Conflict, |
| 210 | "Account purge and its cleanup jobs must finish before completion.", |
| 211 | )); |
| 212 | } |
| 213 | // Each part needs an actual outcome, including a reason for anything retained. |
| 214 | let evidence: serde_json::Value = match serde_json::from_str(&a.evidence) { |
| 215 | Ok(value) => value, |
| 216 | Err(_) => { |
| 217 | return Ok(Outcome::fail( |
| 218 | FailureCode::Invalid, |
| 219 | "Completion evidence must be a JSON object.", |
| 220 | )); |
| 221 | } |
| 222 | }; |
| 223 | for key in [ |
| 224 | "identity", |
| 225 | "downstream", |
| 226 | "shared_content", |
| 227 | "processors", |
| 228 | "backups", |
| 229 | "response", |
| 230 | "retention", |
| 231 | ] { |
| 232 | if evidence |
| 233 | .get(key) |
| 234 | .and_then(serde_json::Value::as_str) |
| 235 | .is_none_or(|v| v.trim().is_empty()) |
| 236 | { |
| 237 | return Ok(Outcome::fail( |
| 238 | FailureCode::Invalid, |
| 239 | format!("Record the {key} outcome before completing this request."), |
| 240 | )); |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | let now = rfc3339(now_ms()); |
| 245 | self.db.batch(vec![ |
| 246 | self.db.prepare("INSERT INTO erasure_reviews (request_id, at, staff, status, evidence) SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM erasure_requests WHERE id = ?1 AND closed_at IS NULL)") |
| 247 | .bind(&[a.id.as_str().into(), now.as_str().into(), a.staff.as_str().into(), a.status.as_str().into(), a.evidence.as_str().into()])?, |
| 248 | self.db.prepare("UPDATE erasure_requests SET status = ?2, evidence = ?3, updated_at = ?4, updated_by = ?5, closed_at = CASE WHEN ?2 IN ('completed','refused') THEN ?4 ELSE NULL END WHERE id = ?1 AND closed_at IS NULL") |
| 249 | .bind(&[a.id.into(), a.status.into(), a.evidence.into(), now.into(), a.staff.into()])?, |
| 250 | ]).await?; |
| 251 | Ok(Outcome::Ok(true)) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | #[cfg(test)] |
| 256 | mod tests { |
| 257 | use super::*; |
| 258 | #[test] |
| 259 | fn calendar_month_deadlines() { |
| 260 | for (from, to) in [ |
| 261 | ("2026-01-31", "2026-02-28"), |
| 262 | ("2028-01-31", "2028-02-29"), |
| 263 | ("2026-12-14", "2027-01-14"), |
| 264 | ("2026-10-14", "2026-11-14"), |
| 265 | ] { |
| 266 | assert_eq!( |
| 267 | deadline(&format!("{from}T15:30:00.123Z")), |
| 268 | format!("{to}T15:30:00.123Z") |
| 269 | ); |
| 270 | } |
| 271 | } |
| 272 | } |