2026-09-30 13:28:28 +02:00
|
|
|
//! Who belongs to which group, kept by portal itself.
|
|
|
|
|
//!
|
|
|
|
|
//! A membership is a case: one record per (group, person) in the
|
|
|
|
|
//! built-in `portal_memberships` bucket, `invited` until the person is
|
|
|
|
|
//! seen, `active` from then on, `removed` when a desk takes them out.
|
|
|
|
|
//! A desk invite writes one, a state that `grants` a group writes one,
|
|
|
|
|
//! the first person and the people the content names as responsible
|
|
|
|
|
//! get theirs at start. Nothing here talks to an identity provider:
|
|
|
|
|
//! the provider says who someone is, and this says what they belong
|
|
|
|
|
//! to. At sign-in, the person's active memberships become their groups,
|
|
|
|
|
//! and the access policy (Cedar, `access.rs`) sees them as the parents
|
|
|
|
|
//! of the `User` entity, exactly as it saw a `groups` claim before.
|
|
|
|
|
//!
|
|
|
|
|
//! **A person is their phone number first, their email second.** A
|
|
|
|
|
//! provider that verifies phone numbers (Vipps) makes the number the
|
|
|
|
|
//! surest thing a record can hold; a form that collected an address
|
|
|
|
|
//! still matches on it. A record's key is the group and whichever of
|
|
|
|
|
//! the two it has, so a second invite of the same person into the same
|
|
|
|
|
//! group finds the first.
|
|
|
|
|
#![cfg(feature = "ssr")]
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
use crate::answers::Answer;
|
|
|
|
|
|
|
|
|
|
pub const BUCKET: &str = "portal_memberships";
|
|
|
|
|
pub const INVITED: &str = "invited";
|
|
|
|
|
pub const ACTIVE: &str = "active";
|
|
|
|
|
pub const REMOVED: &str = "removed";
|
|
|
|
|
/// Stamped on moves nobody at a desk made.
|
|
|
|
|
pub const BY: &str = "portal";
|
|
|
|
|
|
|
|
|
|
/// The country a phone number without one is in, as a calling code.
|
|
|
|
|
fn default_country() -> String {
|
2026-10-07 11:18:46 +02:00
|
|
|
std::env::var("PHONE_COUNTRY")
|
|
|
|
|
.ok()
|
|
|
|
|
.filter(|c| !c.trim().is_empty())
|
|
|
|
|
.unwrap_or_else(|| "+47".to_string())
|
2026-09-30 13:28:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Who a person is, as far as a record or a session knows.
|
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
|
|
|
pub struct Identity {
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub sub: Option<String>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub phone: Option<String>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub email: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A phone number as one string for everyone: digits with a leading
|
|
|
|
|
/// `+` and a country code, `+4791234567`. Spaces, dots and dashes go;
|
|
|
|
|
/// `00` becomes `+`; a bare national number gets the site's country.
|
|
|
|
|
/// `None` for anything that is not a number.
|
|
|
|
|
pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
|
|
|
|
|
let mut digits: String = raw.trim().chars().filter(|c| c.is_ascii_digit()).collect();
|
|
|
|
|
let trimmed = raw.trim();
|
|
|
|
|
if digits.is_empty() || digits.len() < 5 || digits.len() > 15 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
if trimmed.starts_with('+') {
|
|
|
|
|
return Some(format!("+{digits}"));
|
|
|
|
|
}
|
|
|
|
|
if let Some(rest) = digits.strip_prefix("00") {
|
|
|
|
|
return Some(format!("+{rest}"));
|
|
|
|
|
}
|
|
|
|
|
if trimmed.starts_with('(') && !trimmed.contains('+') {
|
|
|
|
|
digits = digits.trim_start_matches('0').to_string();
|
|
|
|
|
}
|
|
|
|
|
Some(format!("{}{digits}", country.trim()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_email(raw: &str) -> Option<String> {
|
2026-10-07 11:18:46 +02:00
|
|
|
let e = raw
|
|
|
|
|
.trim()
|
|
|
|
|
.trim_start_matches("mailto:")
|
|
|
|
|
.trim()
|
|
|
|
|
.to_lowercase();
|
2026-09-30 13:28:28 +02:00
|
|
|
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Identity {
|
|
|
|
|
/// From what a form collected: the first phone-like field and the
|
|
|
|
|
/// first email-like field, by the names forms use for them.
|
|
|
|
|
pub fn from_responses(responses: &serde_json::Value) -> Self {
|
|
|
|
|
let field = |names: &[&str]| {
|
|
|
|
|
names
|
|
|
|
|
.iter()
|
|
|
|
|
.find_map(|n| responses.get(*n).and_then(|v| v.as_str()))
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.map(String::from)
|
|
|
|
|
};
|
|
|
|
|
let country = default_country();
|
|
|
|
|
Identity {
|
|
|
|
|
sub: None,
|
2026-10-07 11:18:46 +02:00
|
|
|
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"])
|
|
|
|
|
.and_then(|p| normalize_phone(&p, &country)),
|
2026-09-30 13:28:28 +02:00
|
|
|
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn from_user(user: &crate::auth::User) -> Self {
|
|
|
|
|
let country = default_country();
|
|
|
|
|
Identity {
|
|
|
|
|
sub: Some(user.sub.clone()),
|
2026-10-07 11:18:46 +02:00
|
|
|
phone: user
|
|
|
|
|
.phone
|
|
|
|
|
.as_deref()
|
|
|
|
|
.and_then(|p| normalize_phone(p, &country)),
|
2026-09-30 13:28:28 +02:00
|
|
|
email: user.email.as_deref().and_then(normalize_email),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One person given as one string: a phone number if it reads as
|
|
|
|
|
/// one, else an address.
|
|
|
|
|
pub fn from_contact(contact: &str) -> Self {
|
|
|
|
|
let country = default_country();
|
|
|
|
|
match normalize_email(contact) {
|
2026-10-07 11:18:46 +02:00
|
|
|
Some(email) => Identity {
|
|
|
|
|
sub: None,
|
|
|
|
|
phone: None,
|
|
|
|
|
email: Some(email),
|
|
|
|
|
},
|
|
|
|
|
None => Identity {
|
|
|
|
|
sub: None,
|
|
|
|
|
phone: normalize_phone(contact, &country),
|
|
|
|
|
email: None,
|
|
|
|
|
},
|
2026-09-30 13:28:28 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What a membership record is keyed by: the phone number, else the
|
|
|
|
|
/// address, else the provider's subject. `None` when there is
|
|
|
|
|
/// nothing to know the person by.
|
|
|
|
|
pub fn key(&self) -> Option<String> {
|
|
|
|
|
self.phone
|
|
|
|
|
.as_deref()
|
|
|
|
|
.or(self.email.as_deref())
|
|
|
|
|
.or(self.sub.as_deref())
|
2026-10-07 11:18:46 +02:00
|
|
|
.map(|k| {
|
|
|
|
|
k.chars()
|
|
|
|
|
.map(|c| {
|
|
|
|
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
|
|
|
|
c
|
|
|
|
|
} else {
|
|
|
|
|
'_'
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
2026-09-30 13:28:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether a record about `other` is about this person: the same
|
|
|
|
|
/// subject, phone number or address, any one of them.
|
|
|
|
|
pub fn matches(&self, other: &Identity) -> bool {
|
|
|
|
|
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
|
2026-10-07 11:18:46 +02:00
|
|
|
same(&self.sub, &other.sub)
|
|
|
|
|
|| same(&self.phone, &other.phone)
|
|
|
|
|
|| same(&self.email, &other.email)
|
2026-09-30 13:28:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What is worth writing on a record from a login: everything the
|
|
|
|
|
/// provider verified that the record did not have.
|
|
|
|
|
fn merged_from(&self, seen: &Identity) -> Identity {
|
|
|
|
|
Identity {
|
|
|
|
|
sub: self.sub.clone().or_else(|| seen.sub.clone()),
|
|
|
|
|
phone: self.phone.clone().or_else(|| seen.phone.clone()),
|
|
|
|
|
email: self.email.clone().or_else(|| seen.email.clone()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One record's id: the group and the person's key.
|
|
|
|
|
pub fn record_id(group: &str, key: &str) -> String {
|
|
|
|
|
format!("{}.{key}", group.trim())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The identity a record holds.
|
|
|
|
|
pub fn identity_of(answer: &Answer) -> Identity {
|
|
|
|
|
Identity {
|
2026-10-07 11:18:46 +02:00
|
|
|
sub: answer
|
|
|
|
|
.responses
|
|
|
|
|
.get("sub")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.map(String::from),
|
|
|
|
|
phone: answer
|
|
|
|
|
.responses
|
|
|
|
|
.get("phone")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.map(String::from),
|
|
|
|
|
email: answer
|
|
|
|
|
.responses
|
|
|
|
|
.get("email")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.map(String::from),
|
2026-09-30 13:28:28 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn group_of(answer: &Answer) -> String {
|
2026-10-07 11:18:46 +02:00
|
|
|
answer
|
|
|
|
|
.responses
|
|
|
|
|
.get("group")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.to_string()
|
2026-09-30 13:28:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The groups `who` is an active member of, from every record.
|
|
|
|
|
pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
|
|
|
|
|
let mut groups: Vec<String> = records
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|a| a.state == ACTIVE && identity_of(a).matches(who))
|
|
|
|
|
.map(group_of)
|
|
|
|
|
.filter(|g| !g.is_empty())
|
|
|
|
|
.collect();
|
|
|
|
|
groups.sort();
|
|
|
|
|
groups.dedup();
|
|
|
|
|
groups
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 11:18:46 +02:00
|
|
|
async fn store(
|
|
|
|
|
state: &crate::server::AppState,
|
|
|
|
|
) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
2026-09-30 13:28:28 +02:00
|
|
|
match state.jetstream.get_key_value(BUCKET).await {
|
|
|
|
|
Ok(store) => Ok(store),
|
|
|
|
|
Err(_) => Ok(state
|
|
|
|
|
.jetstream
|
2026-10-07 11:18:46 +02:00
|
|
|
.create_key_value(async_nats::jetstream::kv::Config {
|
|
|
|
|
bucket: BUCKET.to_string(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-09-30 13:28:28 +02:00
|
|
|
.await?),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every membership record.
|
|
|
|
|
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
2026-10-07 11:18:46 +02:00
|
|
|
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else {
|
|
|
|
|
return Vec::new();
|
|
|
|
|
};
|
2026-09-30 13:28:28 +02:00
|
|
|
let keys: Vec<String> = {
|
|
|
|
|
use futures::TryStreamExt;
|
|
|
|
|
match store.keys().await {
|
|
|
|
|
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
|
|
|
|
Err(_) => Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for key in keys {
|
|
|
|
|
if let Ok(Some(bytes)) = store.get(&key).await {
|
|
|
|
|
if let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) {
|
|
|
|
|
out.push(answer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Moves one record the way a desk button would: through the event
|
|
|
|
|
/// log, onto the projection, out on the answers subject.
|
2026-10-07 11:18:46 +02:00
|
|
|
async fn move_to(
|
|
|
|
|
state: &crate::server::AppState,
|
|
|
|
|
store: &async_nats::jetstream::kv::Store,
|
|
|
|
|
mut answer: Answer,
|
|
|
|
|
target: &str,
|
|
|
|
|
by: &str,
|
|
|
|
|
) -> anyhow::Result<Answer> {
|
2026-09-30 13:28:28 +02:00
|
|
|
let aggregates = state.aggregates.load();
|
2026-10-07 11:18:46 +02:00
|
|
|
let schema = aggregates
|
|
|
|
|
.get(BUCKET)
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
2026-09-30 13:28:28 +02:00
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
|
2026-10-07 11:18:46 +02:00
|
|
|
crate::answers::transition_or_reseed(
|
|
|
|
|
&state.jetstream,
|
|
|
|
|
schema,
|
|
|
|
|
&answer.id,
|
|
|
|
|
target,
|
|
|
|
|
&answer.state,
|
|
|
|
|
payload.clone(),
|
|
|
|
|
now_ms,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
2026-09-30 13:28:28 +02:00
|
|
|
answer.state = target.to_string();
|
|
|
|
|
answer.decided_ms = Some(now_ms);
|
|
|
|
|
answer.decided_by = Some(by.to_string());
|
2026-10-07 11:18:46 +02:00
|
|
|
store
|
|
|
|
|
.put(&answer.id, serde_json::to_vec(&answer)?.into())
|
|
|
|
|
.await?;
|
2026-09-30 13:28:28 +02:00
|
|
|
let parent_hashes = vec![answer.id.clone()];
|
|
|
|
|
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
|
|
|
|
let event = crate::events::AnswerSubmitted {
|
|
|
|
|
chain_hash,
|
|
|
|
|
parent_hashes,
|
|
|
|
|
question_id: answer.question_id.clone(),
|
|
|
|
|
alternative: target.to_string(),
|
|
|
|
|
responses: payload,
|
|
|
|
|
timestamp_ms: now_ms,
|
2026-09-30 15:11:27 +02:00
|
|
|
storyline: answer.storyline,
|
2026-09-30 13:28:28 +02:00
|
|
|
};
|
|
|
|
|
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
|
|
|
|
|
Ok(answer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Puts a person in a group, or finds them there. A new record starts
|
|
|
|
|
/// `invited`; a removed one is invited again; an invited or active one
|
|
|
|
|
/// is left as it is, so a second grant costs nothing. `created` in
|
|
|
|
|
/// what comes back says whether the person had a place already.
|
2026-10-07 11:18:46 +02:00
|
|
|
pub async fn grant(
|
|
|
|
|
state: &crate::server::AppState,
|
|
|
|
|
group: &str,
|
|
|
|
|
name: &str,
|
|
|
|
|
who: &Identity,
|
|
|
|
|
by: &str,
|
|
|
|
|
) -> anyhow::Result<crate::people::Invited> {
|
|
|
|
|
let key = who.key().ok_or_else(|| {
|
|
|
|
|
anyhow::anyhow!("a membership needs a phone number or an address to know the person by")
|
|
|
|
|
})?;
|
2026-09-30 13:28:28 +02:00
|
|
|
let store = store(state).await?;
|
|
|
|
|
// The person's record in this group, however it was keyed: a desk
|
|
|
|
|
// that invited by address and a decision that granted by phone
|
|
|
|
|
// number are talking about one person once a sign-in has joined
|
|
|
|
|
// the two, and must not make two.
|
2026-10-07 11:18:46 +02:00
|
|
|
let existing: Option<Answer> = all(state)
|
|
|
|
|
.await
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
|
|
|
|
|
let id = existing
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|a| a.id.clone())
|
|
|
|
|
.unwrap_or_else(|| record_id(group, &key));
|
2026-09-30 13:28:28 +02:00
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
let invited = |created: bool| crate::people::Invited {
|
|
|
|
|
username: key.clone(),
|
|
|
|
|
display_name: name.to_string(),
|
|
|
|
|
email: who.email.clone().unwrap_or_default(),
|
|
|
|
|
group: group.to_string(),
|
|
|
|
|
reset_link: None,
|
|
|
|
|
link_expires_ms: None,
|
|
|
|
|
created,
|
2026-10-01 18:06:12 +02:00
|
|
|
mail: None,
|
|
|
|
|
fields: Default::default(),
|
2026-09-30 13:28:28 +02:00
|
|
|
already_member: !created,
|
|
|
|
|
};
|
|
|
|
|
match existing {
|
|
|
|
|
Some(answer) if answer.state == REMOVED => {
|
|
|
|
|
move_to(state, &store, answer, INVITED, by).await?;
|
|
|
|
|
Ok(invited(true))
|
|
|
|
|
}
|
|
|
|
|
Some(_) => Ok(invited(false)),
|
|
|
|
|
None => {
|
|
|
|
|
let responses = serde_json::json!({
|
|
|
|
|
"name": name, "group": group, "by": by,
|
|
|
|
|
"phone": who.phone, "email": who.email, "sub": who.sub,
|
|
|
|
|
});
|
2026-10-07 11:18:46 +02:00
|
|
|
crate::answers::store_answer(
|
|
|
|
|
&state.jetstream,
|
|
|
|
|
&state.aggregates.load(),
|
|
|
|
|
BUCKET,
|
|
|
|
|
id,
|
|
|
|
|
"/",
|
|
|
|
|
"Membership",
|
|
|
|
|
&responses,
|
|
|
|
|
now_ms,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2026-09-30 13:28:28 +02:00
|
|
|
Ok(invited(true))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Takes a person out of a group: the record moves to `removed` and
|
|
|
|
|
/// stays, so the desk can see who was in and put them back.
|
2026-10-07 11:18:46 +02:00
|
|
|
pub async fn revoke(
|
|
|
|
|
state: &crate::server::AppState,
|
|
|
|
|
group: &str,
|
|
|
|
|
who: &Identity,
|
|
|
|
|
by: &str,
|
|
|
|
|
) -> anyhow::Result<bool> {
|
|
|
|
|
let Some(key) = who.key() else {
|
|
|
|
|
return Ok(false);
|
|
|
|
|
};
|
2026-09-30 13:28:28 +02:00
|
|
|
let store = store(state).await?;
|
|
|
|
|
let id = record_id(group, &key);
|
2026-10-07 11:18:46 +02:00
|
|
|
let Some(answer) = store
|
|
|
|
|
.get(&id)
|
|
|
|
|
.await?
|
|
|
|
|
.and_then(|b| serde_json::from_slice::<Answer>(&b).ok())
|
|
|
|
|
else {
|
2026-09-30 13:28:28 +02:00
|
|
|
return Ok(false);
|
|
|
|
|
};
|
|
|
|
|
if answer.state == REMOVED {
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
move_to(state, &store, answer, REMOVED, by).await?;
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Someone signed in: every invitation that was for them is now a
|
|
|
|
|
/// membership, with what the provider verified written on the record,
|
|
|
|
|
/// and their active groups come back to be their groups for the
|
|
|
|
|
/// session. Their `sub` is remembered, so the next login matches on
|
|
|
|
|
/// it before anything else.
|
2026-10-07 11:18:46 +02:00
|
|
|
pub async fn on_signed_in(
|
|
|
|
|
state: &crate::server::AppState,
|
|
|
|
|
user: &crate::auth::User,
|
|
|
|
|
) -> Vec<String> {
|
2026-09-30 13:28:28 +02:00
|
|
|
let who = Identity::from_user(user);
|
|
|
|
|
let records = all(state).await;
|
2026-10-07 11:18:46 +02:00
|
|
|
let Ok(store) = store(state).await else {
|
|
|
|
|
return groups_of(&records, &who);
|
|
|
|
|
};
|
2026-09-30 13:28:28 +02:00
|
|
|
let mut settled = Vec::with_capacity(records.len());
|
|
|
|
|
for answer in records {
|
|
|
|
|
let held = identity_of(&answer);
|
|
|
|
|
if !held.matches(&who) {
|
|
|
|
|
settled.push(answer);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let merged = held.merged_from(&who);
|
|
|
|
|
let mut answer = answer;
|
|
|
|
|
if merged != held {
|
|
|
|
|
if let Some(map) = answer.responses.as_object_mut() {
|
|
|
|
|
map.insert("sub".into(), serde_json::json!(merged.sub));
|
|
|
|
|
map.insert("phone".into(), serde_json::json!(merged.phone));
|
|
|
|
|
map.insert("email".into(), serde_json::json!(merged.email));
|
|
|
|
|
}
|
|
|
|
|
if let Ok(bytes) = serde_json::to_vec(&answer) {
|
|
|
|
|
let _ = store.put(&answer.id, bytes.into()).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if answer.state == INVITED {
|
|
|
|
|
match move_to(state, &store, answer.clone(), ACTIVE, BY).await {
|
|
|
|
|
Ok(active) => {
|
|
|
|
|
tracing::info!(group = %group_of(&active), user = %user.username, "an invitation became a membership");
|
|
|
|
|
settled.push(active);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!(id = %answer.id, "an invitation could not become a membership: {e}");
|
|
|
|
|
settled.push(answer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
settled.push(answer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
groups_of(&settled, &who)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-10-07 11:18:46 +02:00
|
|
|
fn record(
|
|
|
|
|
group: &str,
|
|
|
|
|
state: &str,
|
|
|
|
|
phone: Option<&str>,
|
|
|
|
|
email: Option<&str>,
|
|
|
|
|
sub: Option<&str>,
|
|
|
|
|
) -> Answer {
|
2026-09-30 13:28:28 +02:00
|
|
|
Answer {
|
|
|
|
|
id: record_id(group, "k"),
|
|
|
|
|
question_id: "/".into(),
|
|
|
|
|
alternative: "Membership".into(),
|
|
|
|
|
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
|
|
|
|
|
state: state.into(),
|
|
|
|
|
submitted_ms: 0,
|
2026-09-30 15:11:27 +02:00
|
|
|
storyline: 0,
|
2026-09-30 13:28:28 +02:00
|
|
|
decided_ms: None,
|
|
|
|
|
decided_by: None,
|
|
|
|
|
notes: Vec::new(),
|
2026-10-02 11:25:49 +02:00
|
|
|
finished: false,
|
2026-09-30 13:28:28 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_phone_number_is_one_string_for_everyone() {
|
2026-10-07 11:18:46 +02:00
|
|
|
assert_eq!(
|
|
|
|
|
normalize_phone("912 34 567", "+47"),
|
|
|
|
|
Some("+4791234567".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_phone("+47 912-34-567", "+47"),
|
|
|
|
|
Some("+4791234567".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_phone("004791234567", "+47"),
|
|
|
|
|
Some("+4791234567".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_phone("91234567", "+46"),
|
|
|
|
|
Some("+4691234567".into())
|
|
|
|
|
);
|
2026-09-30 13:28:28 +02:00
|
|
|
assert_eq!(normalize_phone("kari@vel.no", "+47"), None);
|
|
|
|
|
assert_eq!(normalize_phone("123", "+47"), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_person_is_their_phone_first_and_their_address_second() {
|
2026-10-07 11:18:46 +02:00
|
|
|
let both = Identity::from_responses(
|
|
|
|
|
&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }),
|
|
|
|
|
);
|
2026-09-30 13:28:28 +02:00
|
|
|
assert_eq!(both.key().as_deref(), Some("_4791234567"));
|
|
|
|
|
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
|
|
|
|
|
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" }));
|
|
|
|
|
assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no"));
|
2026-10-07 11:18:46 +02:00
|
|
|
assert_eq!(
|
|
|
|
|
Identity::from_contact("mailto:Post@Vel.no")
|
|
|
|
|
.email
|
|
|
|
|
.as_deref(),
|
|
|
|
|
Some("post@vel.no")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Identity::from_contact("912 34 567").phone.as_deref(),
|
|
|
|
|
Some("+4791234567")
|
|
|
|
|
);
|
2026-09-30 13:28:28 +02:00
|
|
|
assert!(Identity::default().key().is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
|
2026-10-07 11:18:46 +02:00
|
|
|
let kari = Identity {
|
|
|
|
|
sub: Some("abc".into()),
|
|
|
|
|
phone: Some("+4791234567".into()),
|
|
|
|
|
email: Some("kari@vel.no".into()),
|
|
|
|
|
};
|
|
|
|
|
assert!(Identity {
|
|
|
|
|
sub: None,
|
|
|
|
|
phone: Some("+4791234567".into()),
|
|
|
|
|
email: None
|
|
|
|
|
}
|
|
|
|
|
.matches(&kari));
|
|
|
|
|
assert!(Identity {
|
|
|
|
|
sub: None,
|
|
|
|
|
phone: None,
|
|
|
|
|
email: Some("kari@vel.no".into())
|
|
|
|
|
}
|
|
|
|
|
.matches(&kari));
|
|
|
|
|
assert!(Identity {
|
|
|
|
|
sub: Some("abc".into()),
|
|
|
|
|
phone: None,
|
|
|
|
|
email: Some("old@vel.no".into())
|
|
|
|
|
}
|
|
|
|
|
.matches(&kari));
|
|
|
|
|
assert!(!Identity {
|
|
|
|
|
sub: None,
|
|
|
|
|
phone: Some("+4799999999".into()),
|
|
|
|
|
email: Some("ola@vel.no".into())
|
|
|
|
|
}
|
|
|
|
|
.matches(&kari));
|
2026-09-30 13:28:28 +02:00
|
|
|
assert!(!Identity::default().matches(&kari));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn only_active_memberships_are_groups() {
|
2026-10-07 11:18:46 +02:00
|
|
|
let kari = Identity {
|
|
|
|
|
sub: None,
|
|
|
|
|
phone: Some("+4791234567".into()),
|
|
|
|
|
email: Some("kari@vel.no".into()),
|
|
|
|
|
};
|
2026-09-30 13:28:28 +02:00
|
|
|
let records = vec![
|
|
|
|
|
record("styret", ACTIVE, Some("+4791234567"), None, None),
|
|
|
|
|
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
|
|
|
|
|
record("trafikk", REMOVED, Some("+4791234567"), None, None),
|
|
|
|
|
record("medlemmer", ACTIVE, None, Some("kari@vel.no"), None),
|
|
|
|
|
record("styret", ACTIVE, Some("+4799999999"), None, None),
|
|
|
|
|
];
|
|
|
|
|
assert_eq!(groups_of(&records, &kari), vec!["medlemmer", "styret"]);
|
|
|
|
|
}
|
|
|
|
|
}
|