Skip to main content

pharos_runtime_database/
account_tokens.rs

1//! Atomic password-reset completion over the configured database adapter.
2
3use std::collections::BTreeMap;
4
5use crate::DbBackendConfig;
6use crate::operations::{
7    AppSchema, DatabaseError, DatabaseOperations, Mutation, Predicate, Query, Transaction,
8    WriteValue,
9};
10use serde_json::Value;
11
12/// Reads the selected account and profile for app-owned profile rendering.
13/// Credentials and other users never enter this view.
14pub fn account_profile_view(
15    adapter: &dyn DatabaseOperations,
16    config: &DbBackendConfig,
17    schema: &AppSchema,
18    actor_id: i64,
19) -> Result<Value, DatabaseError> {
20    if actor_id <= 0 {
21        return Err(DatabaseError::AccessDenied);
22    }
23    let mut accounts = adapter.query(
24        config,
25        schema,
26        &Query {
27            entity: "user_account".to_owned(),
28            fields: ["id", "username", "email", "display_name", "is_active"]
29                .into_iter()
30                .map(ToOwned::to_owned)
31                .collect(),
32            filter: Some(Predicate::All(vec![
33                Predicate::Equal("id".to_owned(), Value::from(actor_id)),
34                Predicate::Equal("is_active".to_owned(), Value::Bool(true)),
35            ])),
36            order: Vec::new(),
37            limit: 1,
38            offset: 0,
39        },
40    )?;
41    let mut account = accounts.pop().ok_or(DatabaseError::MissingRecord)?;
42    let profiles = adapter.query(
43        config,
44        schema,
45        &Query {
46            entity: "user_profile".to_owned(),
47            fields: schema
48                .entities
49                .iter()
50                .find(|entity| entity.name == "user_profile")
51                .ok_or(DatabaseError::InvalidContract)?
52                .fields
53                .iter()
54                .map(|field| field.name.clone())
55                .collect(),
56            filter: Some(Predicate::Equal(
57                "user_id".to_owned(),
58                Value::from(actor_id),
59            )),
60            order: Vec::new(),
61            limit: 2,
62            offset: 0,
63        },
64    )?;
65    if profiles.len() > 1 {
66        return Err(DatabaseError::Conflict);
67    }
68    let profile = profiles
69        .into_iter()
70        .next()
71        .ok_or(DatabaseError::MissingRecord)?;
72    account.extend(
73        profile
74            .into_iter()
75            .filter(|(name, _)| !matches!(name.as_str(), "id" | "user_id" | "created_at")),
76    );
77    let subscriptions = adapter.query(
78        config,
79        schema,
80        &Query {
81            entity: "subscription".to_owned(),
82            fields: vec![
83                "id".to_owned(),
84                "user_id".to_owned(),
85                "business_id".to_owned(),
86            ],
87            filter: Some(Predicate::Equal(
88                "user_id".to_owned(),
89                Value::from(actor_id),
90            )),
91            order: Vec::new(),
92            limit: 257,
93            offset: 0,
94        },
95    )?;
96    if subscriptions.len() > 256 {
97        return Err(DatabaseError::CapacityExceeded);
98    }
99    let ids = subscriptions
100        .iter()
101        .map(|row| {
102            row.get("business_id")
103                .cloned()
104                .ok_or(DatabaseError::StorageFailure)
105        })
106        .collect::<Result<Vec<_>, _>>()?;
107    let businesses = crate::operations::query_related_rows(
108        adapter,
109        config,
110        schema,
111        crate::operations::RelatedRowsQuery {
112            entity: "business",
113            foreign_key: "id",
114            keys: &ids,
115            fields: &["id".to_owned(), "name".to_owned(), "slug".to_owned()],
116            maximum: 256,
117        },
118    )?;
119    if businesses.len() > 256 {
120        return Err(DatabaseError::CapacityExceeded);
121    }
122    let mut calendars = adapter.query(
123        config,
124        schema,
125        &Query {
126            entity: "google_calendar_connection".to_owned(),
127            fields: vec!["user_id".to_owned(), "status".to_owned(), "mode".to_owned()],
128            filter: Some(Predicate::All(vec![
129                Predicate::Equal("user_id".to_owned(), Value::from(actor_id)),
130                Predicate::Equal("mode".to_owned(), Value::String("user".to_owned())),
131            ])),
132            order: Vec::new(),
133            limit: 2,
134            offset: 0,
135        },
136    )?;
137    if calendars.len() > 1 {
138        return Err(DatabaseError::Conflict);
139    }
140    for calendar in &mut calendars {
141        let status = calendar
142            .get("status")
143            .cloned()
144            .ok_or(DatabaseError::StorageFailure)?;
145        calendar.insert("connection_status".to_owned(), status);
146    }
147    Ok(
148        serde_json::json!({"users": [account], "subscriptions": subscriptions, "businesses": businesses, "google_calendar_connections": calendars}),
149    )
150}
151
152/// Updates only the signed-in actor's editable profile fields. Privileged
153/// account type, verification state, credentials, and memberships are excluded.
154pub fn update_account_profile(
155    adapter: &dyn DatabaseOperations,
156    config: &DbBackendConfig,
157    schema: &AppSchema,
158    actor_id: i64,
159    input: &serde_json::Map<String, Value>,
160) -> Result<(), DatabaseError> {
161    if actor_id <= 0
162        || input.is_empty()
163        || input.keys().any(|name| {
164            !matches!(
165                name.as_str(),
166                "full_name" | "phone_number" | "age" | "address" | "payment_preference"
167            )
168        })
169        || input
170            .get("full_name")
171            .and_then(Value::as_str)
172            .is_none_or(|value| value.trim().is_empty() || value.len() > 256)
173    {
174        return Err(DatabaseError::InvalidInput);
175    }
176    if input
177        .get("phone_number")
178        .is_some_and(|value| value.as_str().is_none_or(|value| value.len() > 64))
179        || input
180            .get("address")
181            .is_some_and(|value| value.as_str().is_none_or(|value| value.len() > 2048))
182        || input.get("age").is_some_and(|value| {
183            !value.is_null() && value.as_i64().is_none_or(|age| !(0..=150).contains(&age))
184        })
185        || input.get("payment_preference").is_some_and(|value| {
186            value
187                .as_str()
188                .is_none_or(|value| !matches!(value, "pay_on_site" | "credit_card"))
189        })
190    {
191        return Err(DatabaseError::InvalidInput);
192    }
193    let ownership = Predicate::Equal("user_id".to_owned(), Value::from(actor_id));
194    let rows = adapter.query(
195        config,
196        schema,
197        &Query {
198            entity: "user_profile".to_owned(),
199            fields: input.keys().cloned().collect(),
200            filter: Some(ownership.clone()),
201            order: Vec::new(),
202            limit: 2,
203            offset: 0,
204        },
205    )?;
206    if rows.len() > 1 {
207        return Err(DatabaseError::Conflict);
208    }
209    let current = rows.first().ok_or(DatabaseError::MissingRecord)?;
210    let mut guards = vec![ownership];
211    let mut values = BTreeMap::new();
212    for (field, value) in input {
213        let previous = current.get(field).ok_or(DatabaseError::StorageFailure)?;
214        if previous == value {
215            continue;
216        }
217        guards.push(if previous.is_null() {
218            Predicate::IsNull(field.clone())
219        } else {
220            Predicate::Equal(field.clone(), previous.clone())
221        });
222        values.insert(field.clone(), WriteValue::Literal(value.clone()));
223    }
224    if values.is_empty() {
225        return Ok(());
226    }
227    adapter.transact(
228        config,
229        schema,
230        &Transaction {
231            steps: vec![
232                Mutation::RequireCount {
233                    entity: "user_account".to_owned(),
234                    filter: Predicate::All(vec![
235                        Predicate::Equal("id".to_owned(), Value::from(actor_id)),
236                        Predicate::Equal("is_active".to_owned(), Value::Bool(true)),
237                    ]),
238                    minimum: 1,
239                    maximum: Some(1),
240                },
241                Mutation::Update {
242                    entity: "user_profile".to_owned(),
243                    filter: Predicate::All(guards),
244                    values,
245                    expected_rows: Some(1),
246                },
247            ],
248            idempotency_key: None,
249        },
250    )?;
251    Ok(())
252}
253
254/// Creates a customer account and its profile in one configured-backend transaction.
255pub fn register_account(
256    adapter: &dyn DatabaseOperations,
257    config: &DbBackendConfig,
258    schema: &AppSchema,
259    username: &str,
260    email: &str,
261    password_hash: &str,
262) -> Result<i64, DatabaseError> {
263    if username.is_empty()
264        || username.len() > 128
265        || !username
266            .bytes()
267            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
268        || email.is_empty()
269        || email.len() > 254
270        || email.split_once('@').is_none_or(|(local, domain)| {
271            local.is_empty() || domain.is_empty() || !domain.contains('.')
272        })
273        || password_hash.is_empty()
274        || password_hash.len() > 1024
275    {
276        return Err(DatabaseError::InvalidInput);
277    }
278    let result = adapter.transact(
279        config,
280        schema,
281        &Transaction {
282            steps: vec![
283                Mutation::Insert {
284                    entity: "user_account".to_owned(),
285                    values: BTreeMap::from([
286                        (
287                            "username".to_owned(),
288                            WriteValue::Literal(Value::String(username.to_owned())),
289                        ),
290                        (
291                            "email".to_owned(),
292                            WriteValue::Literal(Value::String(email.to_owned())),
293                        ),
294                        (
295                            "password_hash".to_owned(),
296                            WriteValue::Literal(Value::String(password_hash.to_owned())),
297                        ),
298                        (
299                            "is_active".to_owned(),
300                            WriteValue::Literal(Value::Bool(true)),
301                        ),
302                    ]),
303                },
304                Mutation::Insert {
305                    entity: "user_profile".to_owned(),
306                    values: BTreeMap::from([
307                        ("user_id".to_owned(), WriteValue::GeneratedId(0)),
308                        (
309                            "account_type".to_owned(),
310                            WriteValue::Literal(Value::String("business_customer".to_owned())),
311                        ),
312                        (
313                            "payment_preference".to_owned(),
314                            WriteValue::Literal(Value::String("pay_on_site".to_owned())),
315                        ),
316                        (
317                            "email_verified".to_owned(),
318                            WriteValue::Literal(Value::Bool(false)),
319                        ),
320                    ]),
321                },
322            ],
323            idempotency_key: None,
324        },
325    )?;
326    result
327        .generated_ids
328        .first()
329        .copied()
330        .flatten()
331        .filter(|id| *id > 0)
332        .ok_or(DatabaseError::StorageFailure)
333}
334
335/// Persists one reset token and its delivery work in a single adapter transaction.
336pub fn issue_password_reset(
337    adapter: &dyn DatabaseOperations,
338    config: &DbBackendConfig,
339    schema: &AppSchema,
340    email: &str,
341    token_hash: &str,
342    delivery_secret: &str,
343    expires_at: &str,
344) -> Result<bool, DatabaseError> {
345    if email.len() > 320
346        || email.split_once('@').is_none_or(|(local, domain)| {
347            local.is_empty() || domain.is_empty() || !domain.contains('.')
348        })
349        || token_hash.len() != 64
350        || !token_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
351        || delivery_secret.len() != 64
352        || !delivery_secret.bytes().all(|byte| byte.is_ascii_hexdigit())
353        || expires_at.is_empty()
354        || expires_at.len() > 64
355    {
356        return Err(DatabaseError::InvalidInput);
357    }
358    let users = adapter.query(
359        config,
360        schema,
361        &Query {
362            entity: "user_account".to_owned(),
363            fields: vec!["id".to_owned()],
364            filter: Some(Predicate::Equal(
365                "email".to_owned(),
366                Value::String(email.to_owned()),
367            )),
368            order: Vec::new(),
369            limit: 2,
370            offset: 0,
371        },
372    )?;
373    if users.len() > 1 {
374        return Err(DatabaseError::Conflict);
375    }
376    let Some(user) = users.first() else {
377        return Ok(false);
378    };
379    let user_id = user
380        .get("id")
381        .and_then(Value::as_i64)
382        .filter(|id| *id > 0)
383        .ok_or(DatabaseError::StorageFailure)?;
384    adapter.transact(
385        config,
386        schema,
387        &Transaction {
388            steps: vec![
389                Mutation::RequireCount {
390                    entity: "user_account".to_owned(),
391                    filter: Predicate::All(vec![
392                        Predicate::Equal("id".to_owned(), Value::from(user_id)),
393                        Predicate::Equal("email".to_owned(), Value::String(email.to_owned())),
394                    ]),
395                    minimum: 1,
396                    maximum: Some(1),
397                },
398                Mutation::Insert {
399                    entity: "password_reset".to_owned(),
400                    values: BTreeMap::from([
401                        (
402                            "user_id".to_owned(),
403                            WriteValue::Literal(Value::from(user_id)),
404                        ),
405                        (
406                            "token_hash".to_owned(),
407                            WriteValue::Literal(Value::String(token_hash.to_owned())),
408                        ),
409                        (
410                            "expires_at".to_owned(),
411                            WriteValue::Literal(Value::String(expires_at.to_owned())),
412                        ),
413                    ]),
414                },
415                Mutation::Insert {
416                    entity: "notification_delivery".to_owned(),
417                    values: BTreeMap::from([
418                        (
419                            "adapter_id".to_owned(),
420                            WriteValue::Literal(Value::String("local_reset_queue".to_owned())),
421                        ),
422                        (
423                            "channel".to_owned(),
424                            WriteValue::Literal(Value::String("email".to_owned())),
425                        ),
426                        (
427                            "recipient".to_owned(),
428                            WriteValue::Literal(Value::String(email.to_owned())),
429                        ),
430                        (
431                            "template_id".to_owned(),
432                            WriteValue::Literal(Value::String("password_reset".to_owned())),
433                        ),
434                        (
435                            "delivery_status".to_owned(),
436                            WriteValue::Literal(Value::String("queued".to_owned())),
437                        ),
438                        (
439                            "resource_id".to_owned(),
440                            WriteValue::Literal(Value::String(delivery_secret.to_owned())),
441                        ),
442                        (
443                            "max_attempts".to_owned(),
444                            WriteValue::Literal(Value::from(0)),
445                        ),
446                        (
447                            "backoff_seconds".to_owned(),
448                            WriteValue::Literal(Value::from(0)),
449                        ),
450                    ]),
451                },
452            ],
453            idempotency_key: None,
454        },
455    )?;
456    Ok(true)
457}
458
459/// Claims a live reset token, changes its account password, and revokes sessions atomically.
460pub fn complete_password_reset(
461    adapter: &dyn DatabaseOperations,
462    config: &DbBackendConfig,
463    schema: &AppSchema,
464    token_hash: &str,
465    password_hash: &str,
466    now: &str,
467) -> Result<Option<i64>, DatabaseError> {
468    if token_hash.len() != 64
469        || !token_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
470        || password_hash.is_empty()
471        || password_hash.len() > 1024
472        || now.is_empty()
473        || now.len() > 64
474    {
475        return Err(DatabaseError::InvalidInput);
476    }
477    let live_token = Predicate::All(vec![
478        Predicate::Equal(
479            "token_hash".to_owned(),
480            Value::String(token_hash.to_owned()),
481        ),
482        Predicate::IsNull("claimed_at".to_owned()),
483        Predicate::GreaterThan("expires_at".to_owned(), Value::String(now.to_owned())),
484    ]);
485    let resets = adapter.query(
486        config,
487        schema,
488        &Query {
489            entity: "password_reset".to_owned(),
490            fields: vec!["id".to_owned(), "user_id".to_owned()],
491            filter: Some(live_token.clone()),
492            order: Vec::new(),
493            limit: 2,
494            offset: 0,
495        },
496    )?;
497    if resets.len() > 1 {
498        return Err(DatabaseError::Conflict);
499    }
500    let Some(reset) = resets.first() else {
501        return Ok(None);
502    };
503    let reset_id = reset
504        .get("id")
505        .and_then(Value::as_i64)
506        .filter(|id| *id > 0)
507        .ok_or(DatabaseError::StorageFailure)?;
508    let user_id = reset
509        .get("user_id")
510        .and_then(Value::as_i64)
511        .filter(|id| *id > 0)
512        .ok_or(DatabaseError::StorageFailure)?;
513    let reset_filter = Predicate::All(vec![
514        live_token,
515        Predicate::Equal("id".to_owned(), Value::from(reset_id)),
516    ]);
517    let transaction = Transaction {
518        steps: vec![
519            Mutation::RequireCount {
520                entity: "password_reset".to_owned(),
521                filter: reset_filter.clone(),
522                minimum: 1,
523                maximum: Some(1),
524            },
525            Mutation::Update {
526                entity: "password_reset".to_owned(),
527                filter: reset_filter,
528                values: BTreeMap::from([(
529                    "claimed_at".to_owned(),
530                    WriteValue::Literal(Value::String(now.to_owned())),
531                )]),
532                expected_rows: Some(1),
533            },
534            Mutation::Update {
535                entity: "user_account".to_owned(),
536                filter: Predicate::Equal("id".to_owned(), Value::from(user_id)),
537                values: BTreeMap::from([(
538                    "password_hash".to_owned(),
539                    WriteValue::Literal(Value::String(password_hash.to_owned())),
540                )]),
541                expected_rows: Some(1),
542            },
543            Mutation::Update {
544                entity: "auth_session".to_owned(),
545                filter: Predicate::All(vec![
546                    Predicate::Equal("user_id".to_owned(), Value::from(user_id)),
547                    Predicate::IsNull("revoked_at".to_owned()),
548                ]),
549                values: BTreeMap::from([(
550                    "revoked_at".to_owned(),
551                    WriteValue::Literal(Value::String(now.to_owned())),
552                )]),
553                expected_rows: None,
554            },
555        ],
556        idempotency_key: None,
557    };
558    adapter.transact(config, schema, &transaction)?;
559    Ok(Some(user_id))
560}
561
562/// Claims a live email token and marks its account profile verified in one transaction.
563pub fn complete_email_verification(
564    adapter: &dyn DatabaseOperations,
565    config: &DbBackendConfig,
566    schema: &AppSchema,
567    token_hash: &str,
568    now: &str,
569) -> Result<Option<i64>, DatabaseError> {
570    if token_hash.is_empty()
571        || token_hash.len() > 256
572        || token_hash.contains(['\0', '\r', '\n'])
573        || now.is_empty()
574        || now.len() > 64
575    {
576        return Err(DatabaseError::InvalidInput);
577    }
578    let live_token = Predicate::All(vec![
579        Predicate::Equal(
580            "token_hash".to_owned(),
581            Value::String(token_hash.to_owned()),
582        ),
583        Predicate::IsNull("claimed_at".to_owned()),
584        Predicate::GreaterThan("expires_at".to_owned(), Value::String(now.to_owned())),
585    ]);
586    let verifications = adapter.query(
587        config,
588        schema,
589        &Query {
590            entity: "email_verification".to_owned(),
591            fields: vec!["id".to_owned(), "user_id".to_owned()],
592            filter: Some(live_token.clone()),
593            order: Vec::new(),
594            limit: 2,
595            offset: 0,
596        },
597    )?;
598    if verifications.len() > 1 {
599        return Err(DatabaseError::Conflict);
600    }
601    let Some(verification) = verifications.first() else {
602        return Ok(None);
603    };
604    let verification_id = verification
605        .get("id")
606        .and_then(Value::as_i64)
607        .filter(|id| *id > 0)
608        .ok_or(DatabaseError::StorageFailure)?;
609    let user_id = verification
610        .get("user_id")
611        .and_then(Value::as_i64)
612        .filter(|id| *id > 0)
613        .ok_or(DatabaseError::StorageFailure)?;
614    let filter = Predicate::All(vec![
615        live_token,
616        Predicate::Equal("id".to_owned(), Value::from(verification_id)),
617    ]);
618    adapter.transact(
619        config,
620        schema,
621        &Transaction {
622            steps: vec![
623                Mutation::RequireCount {
624                    entity: "email_verification".to_owned(),
625                    filter: filter.clone(),
626                    minimum: 1,
627                    maximum: Some(1),
628                },
629                Mutation::Update {
630                    entity: "email_verification".to_owned(),
631                    filter,
632                    values: BTreeMap::from([(
633                        "claimed_at".to_owned(),
634                        WriteValue::Literal(Value::String(now.to_owned())),
635                    )]),
636                    expected_rows: Some(1),
637                },
638                Mutation::Update {
639                    entity: "user_profile".to_owned(),
640                    filter: Predicate::Equal("user_id".to_owned(), Value::from(user_id)),
641                    values: BTreeMap::from([(
642                        "email_verified".to_owned(),
643                        WriteValue::Literal(Value::Bool(true)),
644                    )]),
645                    expected_rows: Some(1),
646                },
647            ],
648            idempotency_key: None,
649        },
650    )?;
651    Ok(Some(user_id))
652}