1use std::collections::BTreeMap;
5use std::time::Duration;
6
7use pharos_runtime_database::operations::{
8 Mutation, Predicate, Query, SortKey, Transaction, WriteValue,
9};
10use serde_json::{Map, Value, json};
11
12mod calendar_feed;
13mod model;
14mod social_announcements;
15pub(super) mod social_evidence;
16
17use super::{
18 AppRuntime, app_database_operations, current_timestamp_utc, dynamic, runtime_timestamp_seconds,
19};
20
21type PreviewRegistry = BTreeMap<String, (AppRuntime, std::time::Instant)>;
22
23const PROFILE_FIELDS: [&str; 8] = [
24 "website",
25 "instagram",
26 "facebook",
27 "source_url",
28 "soundcloud",
29 "spotify",
30 "youtube",
31 "x_url",
32];
33
34const SOURCE_CHECK_INTERVAL_SECONDS: u64 = 12 * 60 * 60;
35
36fn requires_social_authorization(url: &url::Url) -> bool {
38 pharos_runtime_social::provider::SocialProvider::from_url(url).is_some()
39}
40
41fn password_gate(body: &str) -> bool {
43 let lower = body.to_ascii_lowercase();
44 lower.contains("type=\"password\"")
45 || lower.contains("type='password'")
46 || lower.contains("type=password")
47 || ["<title>login", "<title>log in", "<title>sign in"]
48 .iter()
49 .any(|title| lower.contains(title))
50}
51
52pub(super) fn start_worker(
54 stack: std::sync::Arc<std::sync::RwLock<super::RuntimeStack>>,
55) -> Option<super::job::JobWorkerState> {
56 if !stack.read().ok()?.apps.iter().any(|app| {
57 app.queue_enabled
58 || app.app_id.as_deref() == Some("architect")
59 || dynamic::dynamic_contract_doc(app, "social_connections_contract")
60 .and_then(|value| value.get("enabled").and_then(Value::as_bool))
61 == Some(true)
62 }) {
63 return None;
64 }
65 let state = super::job::JobWorkerState::default()
66 .with_poll_interval_ms(5000)
67 .with_poll_handler(std::sync::Arc::new(move || {
68 let Some(apps) = stack.read().ok().map(|stack| stack.apps.clone()) else {
69 return;
70 };
71 for app in &apps {
72 if super::runtime_shutdown_requested() {
73 return;
74 }
75 #[cfg(not(target_os = "wasi"))]
76 super::social_connections::process_one(app);
77 process_one_source(app);
78 }
79 if !super::runtime_shutdown_requested() {
80 process_registered_previews();
81 }
82 }));
83 super::job::job_worker_start(&state, None).ok()?;
84 Some(state)
85}
86
87fn preview_registry() -> &'static std::sync::Mutex<PreviewRegistry> {
89 static REGISTRY: std::sync::OnceLock<std::sync::Mutex<PreviewRegistry>> =
90 std::sync::OnceLock::new();
91 REGISTRY.get_or_init(|| std::sync::Mutex::new(BTreeMap::new()))
92}
93
94pub(super) fn register_preview(app: &AppRuntime) {
96 if !app.is_editor_preview {
97 return;
98 }
99 let enabled = dynamic::dynamic_contract_doc(app, "event_discovery_contract")
100 .and_then(|value| value.get("enabled").and_then(Value::as_bool))
101 == Some(true)
102 || dynamic::dynamic_contract_doc(app, "social_connections_contract")
103 .and_then(|value| value.get("enabled").and_then(Value::as_bool))
104 == Some(true);
105 let (Some(config), Some(id)) = (&app.runtime_conf_path, &app.app_id) else {
106 return;
107 };
108 let Ok(mut registry) = preview_registry().lock() else {
109 return;
110 };
111 let now = std::time::Instant::now();
112 registry.retain(|_, (_, seen)| now.duration_since(*seen) < Duration::from_secs(86400));
113 let key = format!("{config}\0{id}");
114 if !enabled {
115 registry.remove(&key);
116 return;
117 }
118 if registry.len() >= 32 && !registry.contains_key(&key) {
119 return;
120 }
121 let mut preview = app.clone();
122 preview.queue_enabled = true;
123 registry.insert(key, (preview, now));
124}
125
126pub(super) fn process_registered_previews() {
128 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
129 let preview = preview_registry().lock().ok().and_then(|registry| {
130 if registry.is_empty() {
131 return None;
132 }
133 let index = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % registry.len();
134 registry
135 .values()
136 .nth(index)
137 .filter(|(_, seen)| seen.elapsed() < Duration::from_secs(86400))
138 .map(|(app, _)| app.clone())
139 });
140 if let Some(preview) = preview {
141 #[cfg(not(target_os = "wasi"))]
142 super::social_connections::process_one(&preview);
143 process_one_source(&preview);
144 }
145}
146
147#[derive(Debug, Clone, PartialEq)]
149struct DiscoveredEvent {
150 key: String,
151 title: String,
152 starts_at: String,
153 ends_at: String,
154 venue: String,
155 address: String,
156 country: String,
157 timezone: String,
158 event_state: String,
159 state_explicit: bool,
160 organizers: Vec<String>,
161 event_url: String,
162 ticket_url: String,
163 status: String,
164 evidence: String,
165 evidence_source: String,
166}
167
168#[derive(Clone)]
170struct ReviewCandidate {
171 facts: Value,
172 reason: &'static str,
173 accepted_event_id: Option<i64>,
174}
175
176fn public_link(value: &str) -> Option<String> {
178 Some(super::outbound_url::https_url(value)?.to_string())
179}
180
181fn organizer_sites(store: &super::database::LogicalAppStore) -> Result<Vec<url::Url>, ()> {
183 if !store
184 .schema
185 .entities
186 .iter()
187 .any(|entity| entity.name == "organizer")
188 {
189 return Ok(Vec::new());
190 }
191 let rows = store
192 .adapter
193 .query(
194 &store.config,
195 &store.schema,
196 &Query {
197 entity: "organizer".into(),
198 fields: vec!["website".into()],
199 filter: Some(Predicate::Equal("crm_status".into(), json!("active"))),
200 order: vec![],
201 limit: 1000,
202 offset: 0,
203 },
204 )
205 .map_err(|_| ())?;
206 if rows.len() == 1000 {
207 return Err(());
208 }
209 Ok(rows
210 .iter()
211 .filter_map(|row| {
212 row.get("website")
213 .and_then(Value::as_str)
214 .and_then(super::outbound_url::https_url)
215 })
216 .collect())
217}
218
219fn same_site(left: &url::Url, right: &url::Url) -> bool {
221 left.host_str()
222 .map(|host| host.strip_prefix("www.").unwrap_or(host))
223 == right
224 .host_str()
225 .map(|host| host.strip_prefix("www.").unwrap_or(host))
226 && left.port_or_known_default() == right.port_or_known_default()
227}
228
229fn declared_site_allows(target: &url::Url, site: &url::Url) -> bool {
231 if !same_site(target, site) {
232 return false;
233 }
234 let host = site.host_str().unwrap_or_default();
235 let social = requires_social_authorization(site)
236 || ["youtube.com", "youtu.be", "soundcloud.com", "spotify.com"]
237 .iter()
238 .any(|domain| {
239 host == *domain
240 || host
241 .strip_suffix(domain)
242 .is_some_and(|prefix| prefix.ends_with('.'))
243 });
244 if !social {
245 return true;
246 }
247 let path = site.path().trim_end_matches('/');
248 !path.is_empty()
249 && target.query() == site.query()
250 && (target.path().trim_end_matches('/') == path
251 || target.path().starts_with(&format!("{path}/")))
252}
253
254fn eligible_source(value: &str, artist: &Map<String, Value>, organizers: &[url::Url]) -> bool {
256 let Some(url) = super::outbound_url::https_url(value) else {
257 return false;
258 };
259 let host = url.host_str().unwrap_or_default();
260 if [
261 "bing.com",
262 "google.com",
263 "duckduckgo.com",
264 "search.yahoo.com",
265 "linktr.ee",
266 "linktree.com",
267 ]
268 .iter()
269 .any(|blocked| {
270 host == *blocked
271 || host
272 .strip_suffix(blocked)
273 .is_some_and(|prefix| prefix.ends_with('.'))
274 }) {
275 return false;
276 }
277 if artist
278 .get("source_url")
279 .and_then(Value::as_str)
280 .and_then(super::outbound_url::https_url)
281 .is_some_and(|catalog| {
282 catalog
283 .path()
284 .split('/')
285 .any(|part| matches!(part, "artists" | "artist" | "djs" | "dj"))
286 && same_site(&url, &catalog)
287 && (url.path().trim_end_matches('/') == catalog.path().trim_end_matches('/')
288 || url
289 .path()
290 .starts_with(&format!("{}/", catalog.path().trim_end_matches('/'))))
291 })
292 {
293 return false;
294 }
295 if artist
296 .get("website")
297 .and_then(Value::as_str)
298 .and_then(super::outbound_url::https_url)
299 .is_some_and(|site| declared_site_allows(&url, &site))
300 || organizers
301 .iter()
302 .any(|site| declared_site_allows(&url, site))
303 {
304 return true;
305 }
306 PROFILE_FIELDS
307 .iter()
308 .filter(|field| !matches!(**field, "website" | "source_url"))
309 .any(|field| {
310 artist
311 .get(*field)
312 .and_then(Value::as_str)
313 .and_then(super::outbound_url::https_url)
314 .is_some_and(|profile| {
315 let path = profile.path().trim_end_matches('/');
316 !path.is_empty()
317 && same_site(&url, &profile)
318 && url.query() == profile.query()
319 && (url.path().trim_end_matches('/') == path
320 || url.path().starts_with(&format!("{path}/")))
321 })
322 })
323}
324
325fn normalized_name(value: &str) -> String {
327 value
328 .split_whitespace()
329 .collect::<Vec<_>>()
330 .join(" ")
331 .to_lowercase()
332}
333
334fn performer_matches(value: &Value, name: &str) -> bool {
336 match value {
337 Value::String(value) => normalized_name(value) == normalized_name(name),
338 Value::Array(values) => values.iter().any(|value| performer_matches(value, name)),
339 Value::Object(value) => value
340 .get("name")
341 .is_some_and(|value| performer_matches(value, name)),
342 _ => false,
343 }
344}
345
346fn event_from_value(
348 value: &Value,
349 artist: &str,
350 source: &str,
351 now: u64,
352) -> Option<DiscoveredEvent> {
353 let types = value.get("@type")?;
354 let is_event = |kind: &str| {
355 matches!(
356 kind.rsplit('/').next().unwrap_or(kind),
357 "Event" | "MusicEvent" | "Festival"
358 )
359 };
360 if !types.as_str().is_some_and(is_event)
361 && !types
362 .as_array()
363 .is_some_and(|types| types.iter().filter_map(Value::as_str).any(is_event))
364 {
365 return None;
366 }
367 if !performer_matches(value.get("performer")?, artist) {
368 return None;
369 }
370 let title = value.get("name")?.as_str()?.trim();
371 let raw_start = value.get("startDate")?.as_str()?;
372 if raw_start.len() < 20
373 || !(raw_start.ends_with('Z') || raw_start.get(19..)?.contains(['+', '-']))
374 {
375 return None;
376 }
377 let start = runtime_timestamp_seconds(raw_start)?;
378 if start < now.saturating_sub(30 * 86400)
379 || start > now.saturating_add(730 * 86400)
380 || title.is_empty()
381 || title.len() > 300
382 {
383 return None;
384 }
385 let venue = value.pointer("/location/name")?.as_str()?.trim();
386 if venue.is_empty() || venue.len() > 300 {
387 return None;
388 }
389 let event_url = value
390 .get("url")
391 .and_then(Value::as_str)
392 .and_then(public_link)
393 .unwrap_or_else(|| source.to_owned());
394 let ticket = value
395 .pointer("/offers/url")
396 .or_else(|| value.pointer("/offers/0/url"))
397 .and_then(Value::as_str)
398 .and_then(public_link)
399 .unwrap_or_default();
400 let end = if let Some(raw) = value.get("endDate") {
401 let raw = raw.as_str()?;
402 if raw.len() < 20 || !(raw.ends_with('Z') || raw.get(19..)?.contains(['+', '-'])) {
403 return None;
404 }
405 let end = runtime_timestamp_seconds(raw)?;
406 if end < start {
407 return None;
408 }
409 pharos_runtime_core::time::timestamp_from_epoch_seconds(end)
410 } else {
411 String::new()
412 };
413 let (status, event_state) = match value
414 .get("eventStatus")
415 .and_then(Value::as_str)
416 .unwrap_or("")
417 .rsplit('/')
418 .next()?
419 {
420 "EventCancelled" => ("cancelled", "cancelled"),
421 "EventPostponed" => ("review", "postponed"),
422 "EventRescheduled" => ("published", "rescheduled"),
423 "EventScheduled" | "" => ("published", "scheduled"),
424 _ => ("review", "unknown"),
425 };
426 let key = model::event_identity(value, source, start, venue, title, &event_url)?;
427 let evidence = json!({"extraction_version":1,"event":value}).to_string();
428 if evidence.len() > 7680 {
429 return None;
430 }
431 let (address, country) = model::location(value)?;
432 let organizers = model::organizer_names(value)?;
433 Some(DiscoveredEvent {
434 key,
435 title: title.to_owned(),
436 starts_at: pharos_runtime_core::time::timestamp_from_epoch_seconds(start),
437 ends_at: end,
438 venue: venue.to_owned(),
439 address,
440 country,
441 timezone: if raw_start.ends_with('Z') {
442 "UTC".into()
443 } else {
444 raw_start.get(raw_start.len().checked_sub(6)?..)?.to_owned()
445 },
446 event_state: event_state.into(),
447 state_explicit: value
448 .get("eventStatus")
449 .and_then(Value::as_str)
450 .is_some_and(|state| !state.is_empty()),
451 organizers,
452 event_url,
453 ticket_url: ticket,
454 status: status.to_owned(),
455 evidence,
456 evidence_source: source.to_owned(),
457 })
458}
459
460fn collect_events(
462 value: &Value,
463 artist: &str,
464 source: &str,
465 now: u64,
466 depth: usize,
467 output: &mut BTreeMap<String, DiscoveredEvent>,
468 review: &mut Vec<ReviewCandidate>,
469) {
470 if depth > 8 || output.len() + review.len() >= 100 {
471 return;
472 }
473 let event = event_from_value(value, artist, source, now);
474 let in_window = value
475 .get("startDate")
476 .and_then(Value::as_str)
477 .and_then(runtime_timestamp_seconds)
478 .is_none_or(|start| {
479 start >= now.saturating_sub(30 * 86400) && start <= now.saturating_add(730 * 86400)
480 });
481 if event.is_none()
482 && in_window
483 && value.get("@type").is_some_and(|kind| {
484 let event_type = |name: &str| {
485 matches!(
486 name.rsplit('/').next(),
487 Some("Event" | "MusicEvent" | "Festival")
488 )
489 };
490 kind.as_str().is_some_and(event_type)
491 || kind
492 .as_array()
493 .is_some_and(|kinds| kinds.iter().filter_map(Value::as_str).any(event_type))
494 })
495 && value.to_string().len() <= 7680
496 {
497 review.push(ReviewCandidate {
498 facts: value.clone(),
499 reason: "Incomplete date, venue or exact artist evidence; not published",
500 accepted_event_id: None,
501 });
502 }
503 if let Some(event) = event {
504 match output.entry(event.key.clone()) {
505 std::collections::btree_map::Entry::Occupied(mut entry) => {
506 if entry.get() != &event {
507 entry.get_mut().status = "review".to_owned();
508 review.push(ReviewCandidate {
509 facts: value.clone(),
510 reason: "Conflicting event declarations; not published",
511 accepted_event_id: None,
512 });
513 }
514 }
515 std::collections::btree_map::Entry::Vacant(entry) => {
516 entry.insert(event);
517 }
518 }
519 }
520 match value {
521 Value::Array(values) => {
522 for value in values.iter().take(100) {
523 collect_events(value, artist, source, now, depth + 1, output, review);
524 }
525 }
526 Value::Object(values) => {
527 if let Some(graph) = values.get("@graph") {
528 collect_events(graph, artist, source, now, depth + 1, output, review);
529 }
530 }
531 _ => {}
532 }
533}
534
535#[cfg(test)]
537fn extract_events(body: &str, artist: &str, source: &str, now: u64) -> Vec<DiscoveredEvent> {
538 extract_events_with_review(body, artist, source, now).0
539}
540
541fn extract_events_with_review(
543 body: &str,
544 artist: &str,
545 source: &str,
546 now: u64,
547) -> (Vec<DiscoveredEvent>, Vec<ReviewCandidate>) {
548 if body.len() > 1024 * 1024 || public_link(source).is_none() {
549 return (Vec::new(), Vec::new());
550 }
551 let mut output = BTreeMap::new();
552 if body.trim_start().starts_with("BEGIN:VCALENDAR") {
553 return (
554 calendar_feed::extract(body, artist, source, now).unwrap_or_default(),
555 Vec::new(),
556 );
557 }
558 let mut review = Vec::new();
559 if let Ok(value) = serde_json::from_str::<Value>(body) {
560 collect_events(&value, artist, source, now, 0, &mut output, &mut review);
561 } else {
562 let lowercase = body.to_ascii_lowercase();
563 let mut lower_remainder = lowercase.as_str();
564 let mut remainder = body;
565 for _ in 0..100 {
566 let Some(start) = lower_remainder.find("<script") else {
567 break;
568 };
569 remainder = &remainder[start + 7..];
570 lower_remainder = &lower_remainder[start + 7..];
571 let Some(end_tag) = lower_remainder.find('>') else {
572 break;
573 };
574 let header = &remainder[..end_tag];
575 remainder = &remainder[end_tag + 1..];
576 lower_remainder = &lower_remainder[end_tag + 1..];
577 let Some(end) = lower_remainder.find("</script>") else {
578 break;
579 };
580 if header.to_ascii_lowercase().contains("application/ld+json")
581 && let Ok(value) = serde_json::from_str::<Value>(&remainder[..end])
582 {
583 collect_events(&value, artist, source, now, 0, &mut output, &mut review);
584 }
585 remainder = &remainder[end + 9..];
586 lower_remainder = &lower_remainder[end + 9..];
587 }
588 }
589 (output.into_values().collect(), review)
590}
591
592fn literals(values: Value) -> BTreeMap<String, WriteValue> {
594 values
595 .as_object()
596 .into_iter()
597 .flatten()
598 .map(|(key, value)| (key.clone(), WriteValue::Literal(value.clone())))
599 .collect()
600}
601
602fn prune_social_reviews(store: &super::database::LogicalAppStore, now: u64) -> Result<(), ()> {
604 if !store
605 .schema
606 .entities
607 .iter()
608 .any(|entity| entity.name == "event_observation")
609 {
610 return Ok(());
611 }
612 let rows = store
613 .adapter
614 .query(
615 &store.config,
616 &store.schema,
617 &Query {
618 entity: "event_observation".into(),
619 fields: vec!["id".into(), "source_id".into(), "facts".into()],
620 filter: Some(Predicate::Equal("review_status".into(), json!("review"))),
621 order: vec![SortKey {
622 field: "id".into(),
623 descending: false,
624 }],
625 limit: 1000,
626 offset: 0,
627 },
628 )
629 .map_err(|_| ())?;
630 let mut steps = Vec::new();
631 for row in rows {
632 let raw = row.get("facts").and_then(Value::as_str).ok_or(())?;
633 let facts: Value = serde_json::from_str(raw).map_err(|_| ())?;
634 if facts.get("accepted_event_id").is_some() {
635 continue;
636 }
637 let Some(candidate) = facts.get("candidate") else {
638 continue;
639 };
640 let social =
641 candidate.get("evidence_kind").is_some() || candidate.get("source_profile").is_some();
642 if !social || social_evidence::eligible(candidate, now) {
643 continue;
644 }
645 steps.push(Mutation::Update {entity:"event_observation".into(),filter:Predicate::All(vec![Predicate::Equal("id".into(),row["id"].clone()),Predicate::Equal("source_id".into(),row["source_id"].clone()),Predicate::Equal("facts".into(),row["facts"].clone()),Predicate::Equal("review_status".into(),json!("review"))]),values:literals(json!({"review_status":"rejected","facts":json!({"extraction_version":2,"review_reason":"Excluded: stale, retrospective or unverified post attribution/date evidence","candidate":{}}).to_string()})),expected_rows:Some(1)});
647 }
648 for batch in steps.chunks(100) {
649 store
650 .adapter
651 .transact(
652 &store.config,
653 &store.schema,
654 &Transaction {
655 steps: batch.to_vec(),
656 idempotency_key: None,
657 },
658 )
659 .map_err(|_| ())?;
660 }
661 Ok(())
662}
663
664fn pending_review_evidence(
666 store: &super::database::LogicalAppStore,
667 source: i64,
668 candidates: Vec<ReviewCandidate>,
669) -> Result<Vec<(String, String)>, ()> {
670 let mut evidence = BTreeMap::new();
671 for candidate in candidates {
672 let mut facts = json!({"extraction_version":1,"candidate":candidate.facts,"review_reason":candidate.reason});
673 if let Some(id) = candidate.accepted_event_id {
674 facts["accepted_event_id"] = json!(id);
675 }
676 let facts = facts.to_string();
677 if facts.len() > 8192 {
678 return Err(());
679 }
680 let hash =
681 pharos_runtime_database::operations::operation_fingerprint(&facts).map_err(|_| ())?;
682 evidence.insert(hash, facts);
683 }
684 if evidence.is_empty() {
685 return Ok(Vec::new());
686 }
687 let hashes = evidence.keys().cloned().collect::<Vec<_>>();
688 let mut rows = Vec::new();
689 for batch in hashes.chunks(64) {
690 rows.extend(
691 store
692 .adapter
693 .query(
694 &store.config,
695 &store.schema,
696 &Query {
697 entity: "event_observation".into(),
698 fields: vec!["evidence_hash".into(), "review_status".into()],
699 filter: Some(Predicate::All(vec![
700 Predicate::Equal("source_id".into(), json!(source)),
701 Predicate::In(
702 "evidence_hash".into(),
703 batch.iter().map(|hash| json!(hash)).collect(),
704 ),
705 ])),
706 order: vec![],
707 limit: 64,
708 offset: 0,
709 },
710 )
711 .map_err(|_| ())?,
712 );
713 }
714 for row in rows {
715 if row
716 .get("review_status")
717 .and_then(Value::as_str)
718 .is_some_and(|status| matches!(status, "rejected" | "reviewed" | "accepted"))
719 && let Some(hash) = row.get("evidence_hash").and_then(Value::as_str)
720 {
721 evidence.remove(hash);
722 }
723 }
724 Ok(evidence
725 .into_iter()
726 .map(|(hash, facts)| (facts, hash))
727 .collect())
728}
729
730fn query(
732 store: &super::database::LogicalAppStore,
733 entity: &str,
734 fields: &[&str],
735 filter: Predicate,
736) -> Option<Map<String, Value>> {
737 store
738 .adapter
739 .query(
740 &store.config,
741 &store.schema,
742 &Query {
743 entity: entity.to_owned(),
744 fields: fields.iter().map(|field| (*field).to_owned()).collect(),
745 filter: Some(filter),
746 order: vec![],
747 limit: 1,
748 offset: 0,
749 },
750 )
751 .ok()?
752 .into_iter()
753 .next()
754}
755
756fn synchronize_one_artist(
759 store: &super::database::LogicalAppStore,
760 organizers: &[url::Url],
761) -> bool {
762 let scope = Predicate::Equal("scope_key".into(), json!("artist_source_scan"));
763 let counter = query(store, "crm_counter", &["last_value"], scope.clone());
764 let cursor = counter
765 .as_ref()
766 .and_then(|row| row.get("last_value"))
767 .and_then(Value::as_i64)
768 .unwrap_or(0);
769 let Ok(rows) = store.adapter.query(
770 &store.config,
771 &store.schema,
772 &Query {
773 entity: "artists".into(),
774 fields: std::iter::once("csv_row_id")
775 .chain(PROFILE_FIELDS)
776 .map(str::to_owned)
777 .collect(),
778 filter: Some(Predicate::All(vec![
779 Predicate::GreaterThan("csv_row_id".into(), json!(cursor)),
780 Predicate::Equal("crm_status".into(), json!("active")),
781 ])),
782 order: vec![SortKey {
783 field: "csv_row_id".into(),
784 descending: false,
785 }],
786 limit: 1,
787 offset: 0,
788 },
789 ) else {
790 return false;
791 };
792 let next = rows
793 .first()
794 .and_then(|row| row.get("csv_row_id"))
795 .and_then(Value::as_i64)
796 .unwrap_or(0);
797 if cursor == 0 && next == 0 {
798 return false;
799 }
800 let guard = if counter.is_some() {
801 Predicate::All(vec![
802 scope.clone(),
803 Predicate::Equal("last_value".into(), json!(cursor)),
804 ])
805 } else {
806 scope
807 };
808 let mut steps = vec![
809 Mutation::RequireCount {
810 entity: "crm_counter".into(),
811 filter: guard,
812 minimum: u64::from(counter.is_some()),
813 maximum: Some(u64::from(counter.is_some())),
814 },
815 Mutation::Upsert {
816 entity: "crm_counter".into(),
817 unique_key: vec!["scope_key".into()],
818 values: literals(json!({"scope_key":"artist_source_scan","last_value":next})),
819 },
820 ];
821 if let Some(artist) = rows.first() {
822 let stale = match store.adapter.query(
823 &store.config,
824 &store.schema,
825 &Query {
826 entity: "event_source".into(),
827 fields: vec!["id".into(), "url".into(), "last_status".into()],
828 filter: Some(Predicate::All(vec![
829 Predicate::Equal("artist_row_id".into(), json!(next)),
830 Predicate::NotEqual("last_status".into(), json!("source_removed")),
831 ])),
832 order: vec![],
833 limit: 1000,
834 offset: 0,
835 },
836 ) {
837 Ok(rows) => rows,
838 Err(_) => return false,
839 };
840 let mut artist_guard = vec![
841 Predicate::Equal("csv_row_id".into(), json!(next)),
842 Predicate::Equal("crm_status".into(), json!("active")),
843 ];
844 for field in PROFILE_FIELDS {
845 artist_guard.push(Predicate::Equal(field.into(), artist[field].clone()));
846 }
847 steps.push(Mutation::RequireCount {
848 entity: "artists".into(),
849 filter: Predicate::All(artist_guard),
850 minimum: 1,
851 maximum: Some(1),
852 });
853 for source in stale.iter().filter(|source| {
854 !source
855 .get("url")
856 .and_then(Value::as_str)
857 .is_some_and(|value| eligible_source(value, artist, organizers))
858 }) {
859 steps.push(Mutation::Update {entity:"event_source".into(),filter:Predicate::All(vec![Predicate::Equal("id".into(),source["id"].clone()),Predicate::Equal("url".into(),source["url"].clone()),Predicate::Equal("last_status".into(),source["last_status"].clone())]),values:literals(json!({"enabled":false,"last_status":"source_removed","last_message":"Not a configured artist website, social profile or active organizer site"})),expected_rows:Some(1)});
860 }
861 for name in PROFILE_FIELDS
862 .into_iter()
863 .filter(|name| *name != "source_url")
864 {
865 let Some(url) = artist
866 .get(name)
867 .and_then(Value::as_str)
868 .and_then(public_link)
869 .filter(|url| url.len() <= 512 && eligible_source(url, artist, organizers))
870 else {
871 continue;
872 };
873 steps.push(Mutation::InsertIfAbsent {entity:"event_source".into(),unique_key:vec!["artist_row_id".into(),"url".into()],values:literals(json!({"artist_row_id":next,"url":url,"enabled":true,"managed":true,"next_check_at":current_timestamp_utc(),"last_status":"pending","last_checked_at":"","last_message":""}))});
874 }
875 }
876 if let Err(error) = store.adapter.transact(
877 &store.config,
878 &store.schema,
879 &Transaction {
880 steps,
881 idempotency_key: None,
882 },
883 ) {
884 eprintln!("event source synchronization failed: {error:?}");
885 return false;
886 }
887 next != 0
888}
889
890fn fetch(url: &str) -> Option<pharos_runtime_integrations::webhook::WebhookResponse> {
892 let mut target = super::outbound_url::https_url(url)?;
893 target.set_fragment(None);
894 pharos_runtime_integrations::webhook::send_request(
895 "GET",
896 target.as_str(),
897 None,
898 "",
899 &[
900 ("User-Agent", "PharosEventDiscovery/0.8.4".to_owned()),
901 (
902 "Accept",
903 "application/ld+json, application/json, text/calendar, text/html".to_owned(),
904 ),
905 ],
906 pharos_runtime_integrations::webhook::WebhookLimits {
907 connect_timeout: Duration::from_secs(3),
908 operation_timeout: Duration::from_secs(8),
909 max_response_bytes: 1024 * 1024,
910 allow_plaintext_local: false,
911 },
912 )
913}
914
915fn robots_allowed(url: &str) -> bool {
918 let Some(parsed) = super::outbound_url::https_url(url) else {
919 return false;
920 };
921 let Ok(robots) = parsed.join("/robots.txt") else {
922 return false;
923 };
924 let Some(response) = fetch(robots.as_str()) else {
925 return false;
926 };
927 if response.status_code == 404 {
928 return true;
929 }
930 if response.status_code != 200 || response.body.len() > 65536 {
931 return false;
932 }
933 let path = parsed.query().map_or_else(
934 || parsed.path().to_owned(),
935 |query| format!("{}?{query}", parsed.path()),
936 );
937 robots_body_allows(&path, &response.body)
938}
939
940fn robots_body_allows(path: &str, body: &str) -> bool {
943 if path.contains('%') || body.len() > 65536 || body.lines().count() > 2000 {
944 return false;
945 }
946 let mut applies = false;
947 let mut in_rules = false;
948 let mut allow = 0;
949 let mut deny = 0;
950 for line in body.lines() {
951 let line = line.split('#').next().unwrap_or_default().trim();
952 let Some((field, value)) = line.split_once(':') else {
953 continue;
954 };
955 let value = value.trim();
956 match field.trim().to_ascii_lowercase().as_str() {
957 "user-agent" => {
958 if in_rules {
959 applies = false;
960 in_rules = false;
961 }
962 applies |= value == "*"
963 || value
964 .to_ascii_lowercase()
965 .starts_with("pharoseventdiscovery");
966 }
967 kind @ ("allow" | "disallow") => {
968 in_rules = true;
969 if !applies || value.is_empty() {
970 continue;
971 }
972 if value.contains(['*', '$', '%']) {
973 return false;
974 }
975 let prefix = value;
976 if path.starts_with(prefix) {
977 if kind == "allow" {
978 allow = allow.max(prefix.len());
979 } else {
980 deny = deny.max(prefix.len());
981 }
982 }
983 }
984 "crawl-delay" | "request-rate" if applies => return false,
985 _ => {}
986 }
987 }
988 deny == 0 || allow >= deny
989}
990
991#[derive(Debug, Clone, Copy)]
993enum SourceReadError {
994 AccessBlocked,
995 Unavailable,
996}
997
998pub(super) fn process_one_source(app: &AppRuntime) {
1000 if !app.queue_enabled
1001 || dynamic::dynamic_contract_doc(app, "event_discovery_contract")
1002 .and_then(|value| value.get("enabled").and_then(Value::as_bool))
1003 != Some(true)
1004 {
1005 return;
1006 }
1007 let Some(store) = app_database_operations(app) else {
1008 return;
1009 };
1010 process_store_source_with_app(Some(app), &store, |url| {
1011 if robots_allowed(url) {
1012 fetch(url).ok_or(SourceReadError::Unavailable)
1013 } else {
1014 Err(SourceReadError::AccessBlocked)
1015 }
1016 });
1017}
1018
1019#[cfg(test)]
1021fn process_store_source(
1022 store: &super::database::LogicalAppStore,
1023 read: impl FnOnce(
1024 &str,
1025 )
1026 -> Result<pharos_runtime_integrations::webhook::WebhookResponse, SourceReadError>,
1027) {
1028 process_store_source_with_app(None, store, read);
1029}
1030
1031fn process_store_source_with_app(
1033 app: Option<&AppRuntime>,
1034 store: &super::database::LogicalAppStore,
1035 read: impl FnOnce(
1036 &str,
1037 )
1038 -> Result<pharos_runtime_integrations::webhook::WebhookResponse, SourceReadError>,
1039) {
1040 let Ok(approved_organizers) = organizer_sites(store) else {
1041 return;
1042 };
1043 for _ in 0..16 {
1044 if !synchronize_one_artist(store, &approved_organizers) {
1045 break;
1046 }
1047 }
1048 let now = current_timestamp_utc();
1049 if prune_social_reviews(store, runtime_timestamp_seconds(&now).unwrap_or(u64::MAX)).is_err() {
1050 return;
1051 }
1052 let due = Predicate::All(vec![
1053 Predicate::Equal("enabled".to_owned(), json!(true)),
1054 Predicate::AtMost("next_check_at".to_owned(), json!(now)),
1055 ]);
1056 let Ok(rows) = store.adapter.query(
1057 &store.config,
1058 &store.schema,
1059 &Query {
1060 entity: "event_source".to_owned(),
1061 fields: vec![
1062 "id".to_owned(),
1063 "artist_row_id".to_owned(),
1064 "url".to_owned(),
1065 "next_check_at".to_owned(),
1066 "managed".to_owned(),
1067 ],
1068 filter: Some(due),
1069 order: vec![SortKey {
1070 field: "next_check_at".to_owned(),
1071 descending: false,
1072 }],
1073 limit: 1,
1074 offset: 0,
1075 },
1076 ) else {
1077 return;
1078 };
1079 let Some(source) = rows.first() else {
1080 return;
1081 };
1082 let (Some(id), Some(artist_id), Some(url)) = (
1083 source.get("id").and_then(Value::as_i64),
1084 source.get("artist_row_id").and_then(Value::as_i64),
1085 source.get("url").and_then(Value::as_str),
1086 ) else {
1087 return;
1088 };
1089 let source_filter = Predicate::All(vec![
1090 Predicate::Equal("id".into(), json!(id)),
1091 Predicate::Equal("next_check_at".into(), source["next_check_at"].clone()),
1092 Predicate::Equal("enabled".into(), json!(true)),
1093 ]);
1094 let lease = pharos_runtime_core::time::timestamp_after_seconds_utc(120);
1095 if store.adapter.transact(&store.config, &store.schema, &Transaction {steps:vec![Mutation::Update {entity:"event_source".into(),filter:source_filter,values:literals(json!({"next_check_at":lease,"last_status":"running","last_checked_at":now})),expected_rows:Some(1)}],idempotency_key:None}).is_err() { return; }
1096 let artist_record = query(
1097 store,
1098 "artists",
1099 &std::iter::once("name")
1100 .chain(["crm_status", "record_updated_at"])
1101 .chain(PROFILE_FIELDS)
1102 .collect::<Vec<_>>(),
1103 Predicate::Equal("csv_row_id".into(), json!(artist_id)),
1104 );
1105 let artist = artist_record
1106 .as_ref()
1107 .filter(|row| row.get("crm_status").and_then(Value::as_str) == Some("active"))
1108 .and_then(|row| row.get("name"))
1109 .and_then(Value::as_str)
1110 .filter(|name| !name.trim().is_empty())
1111 .map(str::to_owned);
1112 let removed = artist.is_none()
1113 || !artist_record
1114 .as_ref()
1115 .is_some_and(|record| eligible_source(url, record, &approved_organizers));
1116 let parsed_url = super::outbound_url::https_url(url);
1117 let social_authorization = parsed_url
1118 .as_ref()
1119 .is_some_and(requires_social_authorization);
1120 let allowed = !removed && artist.is_some() && !social_authorization && parsed_url.is_some();
1121 let response = if allowed {
1122 read(url)
1123 } else {
1124 Err(SourceReadError::AccessBlocked)
1125 };
1126 let social_result = if social_authorization && !removed {
1127 app.map(|app| {
1128 queue_social_links(
1129 app,
1130 store,
1131 url,
1132 artist_id,
1133 Predicate::All(vec![
1134 Predicate::Equal("id".into(), json!(id)),
1135 Predicate::Equal("next_check_at".into(), json!(lease)),
1136 Predicate::Equal("url".into(), json!(url)),
1137 Predicate::Equal("enabled".into(), json!(true)),
1138 ]),
1139 artist_record
1140 .as_ref()
1141 .and_then(|row| row.get("record_updated_at"))
1142 .cloned()
1143 .unwrap_or(Value::Null),
1144 )
1145 })
1146 } else {
1147 None
1148 };
1149 let supports_review = store.schema.entities.iter().any(|entity| {
1150 entity.name == "event_observation"
1151 && entity
1152 .fields
1153 .iter()
1154 .any(|field| field.name == "review_status")
1155 });
1156 let mut reviews = Vec::new();
1157 let (mut status, mut detail, events) = match (response, artist) {
1158 (Ok(response), _) if response.body.len() > 1024 * 1024 => (
1159 "failed", "Source response exceeded the intake limit".to_owned(), vec![],
1160 ),
1161 (Ok(response), Some(artist)) if response.status_code == 200 => {
1162 let epoch = runtime_timestamp_seconds(&now).unwrap_or(u64::MAX);
1163 let extracted = if response.body.trim_start().starts_with("BEGIN:VCALENDAR") {
1164 calendar_feed::extract(&response.body, &artist, url, epoch).map(|events|(events,Vec::new()))
1165 } else {
1166 Ok(extract_events_with_review(&response.body, &artist, url, epoch))
1167 };
1168 match extracted {
1169 Ok((events,unaccepted)) => {
1170 reviews=unaccepted;
1171 if events.is_empty() && reviews.is_empty() && password_gate(&response.body) {
1172 ("authorization_required","Source returned a login page rather than an event feed".into(),Vec::new())
1173 } else {
1174 (
1175 if !reviews.is_empty() {"review"} else if events.is_empty() { "no_events" } else { "success" },
1176 format!("{} verified structured events; {} candidates need owner review", events.len(),reviews.len()),
1177 events,
1178 )}},
1179 Err(detail) => ("unsupported_source", detail.to_owned(), vec![]),
1180 }
1181 }
1182 (Ok(response), _) if matches!(response.status_code,404|410) => (
1183 "not_found", "Source reports that the account or page does not exist or has been removed".to_owned(), vec![],
1184 ),
1185 (Ok(response), _) if matches!(response.status_code, 401 | 403) => (
1186 "authorization_required",
1187 "Source requires provider authorization".to_owned(),
1188 vec![],
1189 ),
1190 (Ok(response), _) if response.status_code == 429 => (
1191 "rate_limited",
1192 "Source rate limited this request".to_owned(),
1193 vec![],
1194 ),
1195 (Ok(response), _) => (
1196 "failed",
1197 format!("Source returned HTTP {}", response.status_code),
1198 vec![],
1199 ),
1200 _ if removed => (
1201 "source_removed",
1202 "Source is not a configured artist website, social profile or active organizer site".to_owned(),
1203 vec![],
1204 ),
1205 _ if social_result.as_ref().is_some_and(|result|result.as_ref().is_ok_and(|intake|intake.unverified_posts>0 && intake.verified_posts==0)) => (
1206 "unsupported_source",
1207 "Posts were visible, but their artist attribution or actual posting dates could not be verified. Unverified posts were excluded; no events were inferred from feed text".to_owned(),
1208 vec![],
1209 ),
1210 _ if social_result.as_ref().is_some_and(Result::is_ok) => {
1211 let intake = social_result.as_ref().and_then(|result| result.as_ref().ok());
1212 let events = intake.map(|intake| intake.events.clone()).unwrap_or_default();
1213 (if events.is_empty() {"social_collected"} else {"success"},
1214 format!("{} verified artist performances; {} public source links retained; {} posts have verified posting dates; partial browser view, not a complete feed",events.len(),intake.map_or(0,|intake|intake.link_count),intake.map_or(0,|intake|intake.verified_posts)), events)
1215 },
1216 _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(pharos_runtime_social::SocialError::Busy))) => (
1217 "pending",
1218 "Authorized social collection is queued".into(),
1219 vec![],
1220 ),
1221 _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(pharos_runtime_social::SocialError::RateLimited))) => (
1222 "rate_limited",
1223 "Provider rate limit reached; collection will retry".into(),
1224 vec![],
1225 ),
1226 _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(error) if !matches!(error,pharos_runtime_social::SocialError::AuthorizationRequired|pharos_runtime_social::SocialError::UnsupportedProvider|pharos_runtime_social::SocialError::Forbidden))) => (
1227 "failed",
1228 social_result.as_ref().and_then(|result|result.as_ref().err()).map(ToString::to_string).unwrap_or_default(),
1229 vec![],
1230 ),
1231 _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(pharos_runtime_social::SocialError::UnsupportedProvider))) => (
1232 "connector_unavailable",
1233 pharos_runtime_social::SocialError::UnsupportedProvider.to_string(),
1234 vec![],
1235 ),
1236 _ if social_authorization => (
1237 "authorization_required",
1238 "This social source needs an authorized provider connection".to_owned(),
1239 vec![],
1240 ),
1241 (Err(SourceReadError::AccessBlocked), _) => (
1242 "access_blocked",
1243 "Public crawler access was not permitted or its robots policy could not be verified. The page was not fetched; this does not establish whether the account or page exists".to_owned(),
1244 vec![],
1245 ),
1246 _ => (
1247 "failed",
1248 "Source could not be read safely within the fetch limits".to_owned(),
1249 vec![],
1250 ),
1251 };
1252 if let Some(Ok(intake)) = &social_result {
1253 reviews.extend(intake.reviews.clone());
1254 }
1255 let Ok(existing_events) = model::existing_events(store, &events) else {
1256 return;
1257 };
1258 let mut accepted = Vec::new();
1259 for event in events {
1260 if let Some(existing) = existing_events.get(&event.key)
1261 && model::conflicts(existing, &event, id)
1262 {
1263 let Ok(facts) = serde_json::from_str(&event.evidence) else {
1264 return;
1265 };
1266 reviews.push(ReviewCandidate {facts,reason:"Conflicts with an accepted event; compare retained evidence before updating it",accepted_event_id:existing.get("id").and_then(Value::as_i64)});
1267 } else {
1268 accepted.push(event);
1269 }
1270 }
1271 let events = accepted;
1272 if !reviews.is_empty() {
1273 status = "review";
1274 }
1275 let review_required = !reviews.is_empty();
1276 let reviews = if supports_review {
1277 let Ok(pending) = pending_review_evidence(store, id, reviews) else {
1278 return;
1279 };
1280 pending
1281 } else {
1282 Vec::new()
1283 };
1284 if status == "review" {
1285 status = if review_required && !supports_review {
1286 "unsupported_source"
1287 } else if reviews.is_empty() {
1288 if events.is_empty() {
1289 if review_required {
1290 "reviewed"
1291 } else {
1292 "no_events"
1293 }
1294 } else {
1295 "success"
1296 }
1297 } else {
1298 "review"
1299 };
1300 detail = if review_required && !supports_review {
1301 "Source needs owner review; this app has no review contract".into()
1302 } else {
1303 format!(
1304 "{} verified structured events; {} candidates need owner review",
1305 events.len(),
1306 reviews.len()
1307 )
1308 };
1309 }
1310 let Ok(organizers) = (if events.iter().any(|event| !event.organizers.is_empty()) {
1311 model::known_organizers(store)
1312 } else {
1313 Ok(BTreeMap::new())
1314 }) else {
1315 return;
1316 };
1317 let mut common_guards = vec![Mutation::RequireCount {
1318 entity: "event_source".into(),
1319 filter: Predicate::All(vec![
1320 Predicate::Equal("id".into(), json!(id)),
1321 Predicate::Equal("next_check_at".into(), json!(lease)),
1322 Predicate::Equal("url".into(), json!(url)),
1323 Predicate::Equal("enabled".into(), json!(true)),
1324 ]),
1325 minimum: 1,
1326 maximum: Some(1),
1327 }];
1328 if let Some(Ok(intake)) = &social_result {
1329 common_guards.extend(intake.guards.clone());
1330 }
1331 if !events.is_empty() || !reviews.is_empty() {
1332 let Some(artist) = &artist_record else {
1333 return;
1334 };
1335 common_guards.push(Mutation::RequireCount {
1336 entity: "artists".into(),
1337 filter: Predicate::All(vec![
1338 Predicate::Equal("csv_row_id".into(), json!(artist_id)),
1339 Predicate::Equal("crm_status".into(), json!("active")),
1340 Predicate::Equal(
1341 "record_updated_at".into(),
1342 artist["record_updated_at"].clone(),
1343 ),
1344 ]),
1345 minimum: 1,
1346 maximum: Some(1),
1347 });
1348 }
1349 let mut groups = Vec::new();
1350 if supports_review {
1351 for (facts, evidence_hash) in reviews {
1352 let Ok(event_key) =
1353 pharos_runtime_database::operations::operation_fingerprint(&(url, &evidence_hash))
1354 else {
1355 continue;
1356 };
1357 groups.push(vec![Mutation::InsertIfAbsent {entity:"event_observation".into(),unique_key:vec!["source_id".into(),"evidence_hash".into()],values:literals(json!({"source_id":id,"event_key":event_key,"evidence_hash":evidence_hash,"source_url":url,"observed_at":now,"facts":facts,"review_status":"review"}))}]);
1358 }
1359 }
1360 let Some(event_entity) = store
1361 .schema
1362 .entities
1363 .iter()
1364 .find(|entity| entity.name == "gig_event")
1365 else {
1366 return;
1367 };
1368 for event in events {
1369 let mut mutations = Vec::new();
1370 let evidence_hash =
1371 pharos_runtime_database::operations::operation_fingerprint(&event.evidence)
1372 .unwrap_or_default();
1373 if evidence_hash.is_empty() {
1374 continue;
1375 }
1376 let mut observation = json!({"source_id":id,"event_key":event.key,"evidence_hash":evidence_hash,"source_url":event.evidence_source,"observed_at":now,"facts":event.evidence});
1377 if supports_review {
1378 observation["review_status"] = json!("accepted");
1379 }
1380 mutations.push(Mutation::InsertIfAbsent {
1381 entity: "event_observation".into(),
1382 unique_key: vec!["source_id".into(), "evidence_hash".into()],
1383 values: literals(observation),
1384 });
1385 let existing = existing_events.get(&event.key);
1386 if existing
1387 .as_ref()
1388 .is_some_and(|row| row.get("manual_locked").and_then(Value::as_bool) == Some(true))
1389 {
1390 groups.push(mutations);
1391 continue;
1392 }
1393 let filter = if let Some(existing) = existing {
1394 Predicate::All(
1395 existing
1396 .iter()
1397 .map(|(name, value)| Predicate::Equal(name.clone(), value.clone()))
1398 .collect(),
1399 )
1400 } else {
1401 Predicate::Equal("event_key".into(), json!(event.key))
1402 };
1403 mutations.push(Mutation::RequireCount {
1404 entity: "gig_event".into(),
1405 filter,
1406 minimum: u64::from(existing.is_some()),
1407 maximum: Some(u64::from(existing.is_some())),
1408 });
1409 let matched_organizers = event
1410 .organizers
1411 .iter()
1412 .filter_map(|name| organizers.get(&normalized_name(name)))
1413 .collect::<Vec<_>>();
1414 let Some(mut values) = model::write_values(
1415 existing,
1416 &event,
1417 artist_id,
1418 id,
1419 &now,
1420 matched_organizers
1421 .first()
1422 .and_then(|row| row.get("id"))
1423 .and_then(Value::as_i64),
1424 ) else {
1425 return;
1426 };
1427 values.retain(|name, _| event_entity.fields.iter().any(|field| field.name == *name));
1428 mutations.push(Mutation::Upsert {
1429 entity: "gig_event".into(),
1430 unique_key: vec!["event_key".into()],
1431 values: literals(Value::Object(values)),
1432 });
1433 mutations.push(Mutation::InsertIfAbsent {
1434 entity: "gig_artist".into(),
1435 unique_key: vec!["artist_row_id".into(), "event_key".into()],
1436 values: literals(json!({"artist_row_id":artist_id,"event_key":event.key})),
1437 });
1438 for organizer in matched_organizers {
1439 mutations.push(Mutation::RequireCount {
1440 entity: "organizer".into(),
1441 filter: Predicate::All(
1442 organizer
1443 .iter()
1444 .map(|(name, value)| Predicate::Equal(name.clone(), value.clone()))
1445 .collect(),
1446 ),
1447 minimum: 1,
1448 maximum: Some(1),
1449 });
1450 mutations.push(Mutation::InsertIfAbsent {
1451 entity: "gig_organizer".into(),
1452 unique_key: vec!["event_key".into(), "organizer_id".into()],
1453 values: literals(json!({"event_key":event.key,"organizer_id":organizer["id"]})),
1454 });
1455 }
1456 groups.push(mutations);
1457 }
1458 let mut source_values = json!({"next_check_at":pharos_runtime_core::time::timestamp_after_seconds_utc(if status=="pending" {30} else {SOURCE_CHECK_INTERVAL_SECONDS}),"last_status":status,"last_message":detail,"last_checked_at":now,"enabled":!removed});
1459 if matches!(
1460 status,
1461 "success" | "no_events" | "review" | "reviewed" | "social_collected"
1462 ) && store.schema.entities.iter().any(|entity| {
1463 entity.name == "event_source"
1464 && entity
1465 .fields
1466 .iter()
1467 .any(|field| field.name == "last_success_at")
1468 }) {
1469 source_values["last_success_at"] = json!(now);
1470 }
1471 let completion = Mutation::Update {
1472 entity: "event_source".into(),
1473 filter: Predicate::Equal("id".into(), json!(id)),
1474 values: literals(source_values),
1475 expected_rows: Some(1),
1476 };
1477 if let Err(error) = commit_event_groups(store, &common_guards, groups, completion) {
1478 eprintln!("event discovery acceptance failed: {error:?}");
1479 let mut steps = common_guards;
1481 steps.push(Mutation::Update {entity:"event_source".into(),filter:Predicate::Equal("id".into(),json!(id)),values:literals(json!({"last_status":"failed","last_message":"Event evidence could not be saved; no unvalidated performance was published","last_checked_at":now,"next_check_at":pharos_runtime_core::time::timestamp_after_seconds_utc(SOURCE_CHECK_INTERVAL_SECONDS)})),expected_rows:Some(1)});
1482 if let Err(problem) = store.adapter.transact(
1483 &store.config,
1484 &store.schema,
1485 &Transaction {
1486 steps,
1487 idempotency_key: None,
1488 },
1489 ) {
1490 eprintln!("event discovery failure status unavailable: {problem:?}");
1491 }
1492 }
1493}
1494
1495fn commit_event_groups(
1497 store: &super::database::LogicalAppStore,
1498 guards: &[Mutation],
1499 groups: Vec<Vec<Mutation>>,
1500 completion: Mutation,
1501) -> Result<(), pharos_runtime_database::operations::DatabaseError> {
1502 if groups
1503 .iter()
1504 .any(|group| group.len() + guards.len() + 1 > 200)
1505 {
1506 return Err(pharos_runtime_database::operations::DatabaseError::InvalidInput);
1507 }
1508 let mut steps = guards.to_vec();
1509 for group in groups {
1510 if steps.len() + group.len() + 1 > 200 {
1511 store.adapter.transact(
1512 &store.config,
1513 &store.schema,
1514 &Transaction {
1515 steps: std::mem::replace(&mut steps, guards.to_vec()),
1516 idempotency_key: None,
1517 },
1518 )?;
1519 }
1520 steps.extend(group);
1521 }
1522 steps.push(completion);
1523 store.adapter.transact(
1524 &store.config,
1525 &store.schema,
1526 &Transaction {
1527 steps,
1528 idempotency_key: None,
1529 },
1530 )?;
1531 Ok(())
1532}
1533
1534struct SocialIntake {
1536 link_count: usize,
1537 unverified_posts: usize,
1538 verified_posts: usize,
1539 events: Vec<DiscoveredEvent>,
1540 reviews: Vec<ReviewCandidate>,
1541 guards: Vec<Mutation>,
1542}
1543
1544fn social_review_candidates(value: &Value, depth: usize, output: &mut Vec<ReviewCandidate>) {
1546 let now = runtime_timestamp_seconds(¤t_timestamp_utc()).unwrap_or(u64::MAX);
1547 if depth > 12 || output.len() >= 40 {
1548 return;
1549 }
1550 if value.get("evidence_kind").and_then(Value::as_str) == Some("rendered_link") {
1551 let event_link = value
1552 .get("url")
1553 .and_then(Value::as_str)
1554 .and_then(public_link)
1555 .and_then(|value| url::Url::parse(&value).ok())
1556 .is_some_and(|url| {
1557 url.path_segments().is_some_and(|mut segments| {
1558 segments.any(|segment| {
1559 matches!(
1560 segment.to_ascii_lowercase().as_str(),
1561 "ticket"
1562 | "tickets"
1563 | "event"
1564 | "events"
1565 | "festival"
1566 | "festivals"
1567 | "gig"
1568 | "gigs"
1569 )
1570 })
1571 })
1572 });
1573 if event_link && social_evidence::eligible(value, now) && value.to_string().len() <= 7680 {
1574 let mut facts = value.clone();
1575 facts["@type"] = json!("Event");
1576 output.push(ReviewCandidate {facts,reason:"Event or ticket link observed on the authorized artist page; verify event facts and public publication rights before publishing",accepted_event_id:None});
1577 }
1578 return;
1579 }
1580 if value.get("evidence_kind").and_then(Value::as_str) == Some("rendered_post") {
1581 let text = format!(
1582 "{} {}",
1583 value
1584 .get("description")
1585 .and_then(Value::as_str)
1586 .unwrap_or(""),
1587 value
1588 .get("image_text")
1589 .and_then(Value::as_str)
1590 .unwrap_or("")
1591 )
1592 .to_lowercase();
1593 if [
1594 "event",
1595 "festival",
1596 "gig",
1597 "perform",
1598 "playing",
1599 "live set",
1600 "ticket",
1601 "concert",
1602 "tour",
1603 "出演",
1604 "イベント",
1605 "フェス",
1606 "ライブ",
1607 "公演",
1608 ]
1609 .iter()
1610 .any(|word| text.contains(word))
1611 && social_evidence::eligible(value, now)
1612 && value.to_string().len() <= 7680
1613 {
1614 let mut candidate = value.clone();
1615 candidate["@type"] = json!("Event");
1616 output.push(ReviewCandidate {facts:candidate,reason:"Rendered social post may describe a performance; verify dates, artist, venue and public publication rights before publishing",accepted_event_id:None});
1617 }
1618 return;
1619 }
1620 if value.get("@type").is_some_and(|kind| {
1621 let matches = |kind: &str| {
1622 matches!(
1623 kind.rsplit('/').next(),
1624 Some("Event" | "MusicEvent" | "Festival")
1625 )
1626 };
1627 kind.as_str().is_some_and(matches)
1628 || kind
1629 .as_array()
1630 .is_some_and(|kinds| kinds.iter().filter_map(Value::as_str).any(matches))
1631 }) && social_evidence::eligible(value, now)
1632 && value.to_string().len() <= 7680
1633 {
1634 output.push(ReviewCandidate {facts:value.clone(),reason:"Authorized social event evidence; verify dates, artist, venue and public publication rights before publishing",accepted_event_id:None});
1635 }
1636 if let Some(values) = value
1637 .as_array()
1638 .or_else(|| value.get("@graph").and_then(Value::as_array))
1639 {
1640 for value in values.iter().take(40) {
1641 social_review_candidates(value, depth + 1, output);
1642 }
1643 }
1644}
1645
1646#[cfg(not(target_os = "wasi"))]
1648fn queue_social_links(
1649 app: &AppRuntime,
1650 store: &super::database::LogicalAppStore,
1651 profile: &str,
1652 artist: i64,
1653 source_guard: Predicate,
1654 artist_revision: Value,
1655) -> Result<SocialIntake, pharos_runtime_social::SocialError> {
1656 let observations = super::social_connections::observations_for_profile(
1657 app,
1658 profile,
1659 SOURCE_CHECK_INTERVAL_SECONDS as i64,
1660 )?;
1661 let mut reviews = Vec::new();
1662 let artist_name = query(
1663 store,
1664 "artists",
1665 &["name"],
1666 Predicate::Equal("csv_row_id".into(), json!(artist)),
1667 )
1668 .and_then(|row| row.get("name").and_then(Value::as_str).map(str::to_owned))
1669 .ok_or(pharos_runtime_social::SocialError::InvalidResponse)?;
1670 let now = runtime_timestamp_seconds(¤t_timestamp_utc()).unwrap_or(u64::MAX);
1671 let mut events = BTreeMap::new();
1672 for value in observations.evidence.iter().take(40) {
1673 if let Some(mut event) = social_announcements::extract(value, &artist_name, now) {
1674 event.key =
1675 pharos_runtime_database::operations::operation_fingerprint(&(artist, &event.key))
1676 .map_err(|_| pharos_runtime_social::SocialError::InvalidResponse)?;
1677 events.insert(event.key.clone(), event);
1678 } else {
1679 social_review_candidates(value, 0, &mut reviews);
1680 }
1681 }
1682 let mut events = events.into_values().collect::<Vec<_>>();
1683 social_announcements::retain_existing_identities(store, artist, &mut events)?;
1684 let guards = observations.guards.clone();
1685 let unverified_posts = observations.unverified_posts;
1686 let verified_posts = observations.verified_posts;
1687 let link_count =
1688 accept_social_links(store, artist, source_guard, artist_revision, observations)?;
1689 Ok(SocialIntake {
1690 link_count,
1691 unverified_posts,
1692 verified_posts,
1693 events,
1694 reviews,
1695 guards,
1696 })
1697}
1698
1699#[cfg(not(target_os = "wasi"))]
1701fn accept_social_links(
1702 store: &super::database::LogicalAppStore,
1703 artist: i64,
1704 source_guard: Predicate,
1705 artist_revision: Value,
1706 observations: super::social_connections::ObservationBatch,
1707) -> Result<usize, pharos_runtime_social::SocialError> {
1708 let fields = std::iter::once("csv_row_id")
1709 .chain(PROFILE_FIELDS)
1710 .collect::<Vec<_>>();
1711 let artist_profile = query(
1712 store,
1713 "artists",
1714 &fields,
1715 Predicate::Equal("csv_row_id".into(), json!(artist)),
1716 )
1717 .ok_or(pharos_runtime_social::SocialError::AuthorizationRequired)?;
1718 let organizers =
1719 organizer_sites(store).map_err(|_| pharos_runtime_social::SocialError::Transport)?;
1720 let mut links = std::collections::BTreeSet::new();
1721 for url in observations.links {
1722 let Some(url) = public_link(&url)
1723 .and_then(|value| url::Url::parse(&value).ok())
1724 .filter(|url| {
1725 !requires_social_authorization(url)
1726 && eligible_source(url.as_str(), &artist_profile, &organizers)
1727 })
1728 else {
1729 continue;
1730 };
1731 if url.as_str().len() > 512 {
1732 return Err(pharos_runtime_social::SocialError::Capacity);
1733 }
1734 links.insert(url.to_string());
1735 }
1736 if links.len() > 1000 {
1737 return Err(pharos_runtime_social::SocialError::Capacity);
1738 }
1739 let now = current_timestamp_utc();
1740 let mut steps = observations.guards;
1741 steps.push(Mutation::RequireCount {
1742 entity: "event_source".into(),
1743 filter: source_guard,
1744 minimum: 1,
1745 maximum: Some(1),
1746 });
1747 steps.push(Mutation::RequireCount {
1748 entity: "artists".into(),
1749 filter: Predicate::All(vec![
1750 Predicate::Equal("csv_row_id".into(), json!(artist)),
1751 Predicate::Equal("crm_status".into(), json!("active")),
1752 Predicate::Equal("record_updated_at".into(), artist_revision),
1753 ]),
1754 minimum: 1,
1755 maximum: Some(1),
1756 });
1757 for url in &links {
1758 steps.push(Mutation::InsertIfAbsent {entity:"event_source".into(),unique_key:vec!["artist_row_id".into(),"url".into()],values:literals(json!({"artist_row_id":artist,"url":url,"enabled":true,"managed":false,"next_check_at":now,"last_status":"pending","last_checked_at":"","last_message":"From authorized social content; public evidence validation pending"}))});
1759 }
1760 store
1761 .adapter
1762 .transact(
1763 &store.config,
1764 &store.schema,
1765 &Transaction {
1766 steps,
1767 idempotency_key: None,
1768 },
1769 )
1770 .map_err(|_| pharos_runtime_social::SocialError::Transport)?;
1771 Ok(links.len())
1772}
1773
1774#[cfg(target_os = "wasi")]
1776fn queue_social_links(
1777 _app: &AppRuntime,
1778 _store: &super::database::LogicalAppStore,
1779 _profile: &str,
1780 _artist: i64,
1781 _source_guard: Predicate,
1782 _artist_revision: Value,
1783) -> Result<SocialIntake, pharos_runtime_social::SocialError> {
1784 Err(pharos_runtime_social::SocialError::UnsupportedProvider)
1785}
1786
1787pub(super) fn social_connection_available(
1789 app: &AppRuntime,
1790 provider: pharos_runtime_social::provider::SocialProvider,
1791 guards: &[Mutation],
1792) -> Result<(), pharos_runtime_social::SocialError> {
1793 use pharos_runtime_social::SocialError;
1794 if dynamic::dynamic_contract_doc(app, "event_discovery_contract")
1795 .and_then(|value| value.get("enabled").and_then(Value::as_bool))
1796 != Some(true)
1797 {
1798 return Ok(());
1799 }
1800 let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
1801 let rows = store
1802 .adapter
1803 .query(
1804 &store.config,
1805 &store.schema,
1806 &Query {
1807 entity: "event_source".into(),
1808 fields: vec![
1809 "id".into(),
1810 "url".into(),
1811 "next_check_at".into(),
1812 "last_status".into(),
1813 ],
1814 filter: Some(Predicate::All(vec![
1815 Predicate::Equal("enabled".into(), json!(true)),
1816 Predicate::In(
1817 "last_status".into(),
1818 vec![
1819 json!("authorization_required"),
1820 json!("connector_unavailable"),
1821 ],
1822 ),
1823 ])),
1824 order: vec![],
1825 limit: 1000,
1826 offset: 0,
1827 },
1828 )
1829 .map_err(|_| SocialError::Transport)?;
1830 let now = current_timestamp_utc();
1831 let mut changes = Vec::new();
1832 for row in rows {
1833 if row
1834 .get("url")
1835 .and_then(Value::as_str)
1836 .and_then(|value| url::Url::parse(value).ok())
1837 .as_ref()
1838 .and_then(pharos_runtime_social::provider::SocialProvider::from_url)
1839 != Some(provider)
1840 {
1841 continue;
1842 }
1843 changes.push(Mutation::Update {entity:"event_source".into(),filter:Predicate::All(vec![Predicate::Equal("id".into(),row["id"].clone()),Predicate::Equal("next_check_at".into(),row["next_check_at"].clone()),Predicate::Equal("last_status".into(),row["last_status"].clone()),Predicate::Equal("enabled".into(),json!(true))]),values:literals(json!({"next_check_at":now,"last_status":"pending","last_message":"Website connection saved; collection queued"})),expected_rows:Some(1)});
1844 }
1845 for batch in changes.chunks(150) {
1846 let mut steps = guards.to_vec();
1847 steps.extend_from_slice(batch);
1848 store
1849 .adapter
1850 .transact(
1851 &store.config,
1852 &store.schema,
1853 &Transaction {
1854 steps,
1855 idempotency_key: None,
1856 },
1857 )
1858 .map_err(|_| SocialError::Busy)?;
1859 }
1860 Ok(())
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865 use super::*;
1866
1867 #[test]
1869 fn robots_rules_fail_closed_for_unsupported_patterns() {
1870 assert!(robots_body_allows(
1871 "/events",
1872 "User-agent: *\nDisallow: /private"
1873 ));
1874 assert!(!robots_body_allows(
1875 "/private/events",
1876 "User-agent: *\nDisallow: /private"
1877 ));
1878 assert!(robots_body_allows(
1879 "/private/public",
1880 "User-agent: *\nDisallow: /private\nAllow: /private/public"
1881 ));
1882 assert!(!robots_body_allows(
1883 "/private/events",
1884 "User-agent: *\nAllow: /\nDisallow: /*private"
1885 ));
1886 assert!(!robots_body_allows(
1887 "/%70rivate",
1888 "User-agent: *\nDisallow: /private"
1889 ));
1890 assert!(!robots_body_allows(
1891 "/?private=1",
1892 "User-agent: *\nDisallow: /?private"
1893 ));
1894 assert!(!robots_body_allows(
1895 "/events",
1896 "User-agent: *\nCrawl-delay: 60"
1897 ));
1898 }
1899
1900 #[test]
1902 fn discovery_sources_stay_on_declared_sites() {
1903 let artist=json!({"website":"https://artist.example/","facebook":"https://www.facebook.com/artist","source_url":"https://label.example/artists/artist"}).as_object().unwrap().clone();
1904 let organizers = vec![url::Url::parse("https://festival.example").unwrap()];
1905 let direct =
1906 json!({"website":"https://artist.example/","source_url":"https://artist.example/"})
1907 .as_object()
1908 .unwrap()
1909 .clone();
1910 assert!(eligible_source(
1911 "https://artist.example/events",
1912 &direct,
1913 &[]
1914 ));
1915 let social_website = json!({"website":"https://facebook.com/artist"})
1916 .as_object()
1917 .unwrap()
1918 .clone();
1919 assert!(!eligible_source(
1920 "https://facebook.com/other",
1921 &social_website,
1922 &[]
1923 ));
1924 for value in [
1925 "https://artist.example/events",
1926 "https://www.artist.example/events",
1927 "https://festival.example/gig",
1928 "https://facebook.com/artist/posts/123",
1929 ] {
1930 assert!(eligible_source(value, &artist, &organizers), "{value}");
1931 }
1932 for value in [
1933 "https://www.bing.com/search?q=artist",
1934 "https://linktr.ee/artist",
1935 "https://label.example/artists/artist?tracking=1",
1936 "https://unrelated.example/event",
1937 "https://facebook.com/anotherartist",
1938 "https://artist.example.evil.test/events",
1939 ] {
1940 assert!(!eligible_source(value, &artist, &organizers), "{value}");
1941 }
1942 }
1943
1944 #[test]
1946 fn social_sources_require_authorized_connectors() {
1947 for host in ["facebook.com", "www.instagram.com", "twitter.com", "x.com"] {
1948 assert!(requires_social_authorization(
1949 &url::Url::parse(&format!("https://{host}/artist")).unwrap()
1950 ));
1951 }
1952 for host in ["artist.example", "notfacebook.com", "facebook.com.example"] {
1953 assert!(!requires_social_authorization(
1954 &url::Url::parse(&format!("https://{host}/artist")).unwrap()
1955 ));
1956 }
1957 }
1958
1959 #[test]
1961 #[ignore = "requires PHAROS_CRM_CONTRACT pointing to the staged music catalog contract"]
1962 fn recorded_events_are_idempotent_and_preserve_manual_corrections() {
1963 let path = std::env::var("PHAROS_CRM_CONTRACT").unwrap();
1964 let value =
1965 pharos_runtime_compiler::database_yaml::parse(&std::fs::read_to_string(path).unwrap())
1966 .unwrap();
1967 let contract: pharos_runtime_database::contract::AppDataContract =
1968 serde_json::from_value(value.clone()).unwrap();
1969 let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1970 .join("tmp/0.8.4-testing/music-crm/discovery-tests");
1971 std::fs::create_dir_all(&dir).unwrap();
1972 let db = dir.join(format!(
1973 "events-{}-{}.sqlite3",
1974 std::process::id(),
1975 super::super::current_timestamp_utc().replace([':', '.'], "-")
1976 ));
1977 let backend = std::env::var("PHAROS_CRM_TEST_BACKEND").unwrap_or_else(|_| "sqlite".into());
1978 let mut config = pharos_runtime_database::DbBackendConfig {
1979 backend: backend.clone(),
1980 host: "127.0.0.1".into(),
1981 tls_mode: "disable".into(),
1982 ..Default::default()
1983 };
1984 if backend != "sqlite" {
1985 config.database = std::env::var("PHAROS_CRM_TEST_DATABASE").unwrap();
1986 assert!(
1987 config.database.starts_with("pharos_music_cert_")
1988 && config.database.len() <= 63
1989 && config
1990 .database
1991 .bytes()
1992 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1993 );
1994 }
1995 match backend.as_str() {
1996 "sqlite" => config.sqlite_path = Some(db.clone()),
1997 "postgresql" => {
1998 config.port = "25432".into();
1999 config.user = std::env::var("PHAROS_CRM_TEST_PG_USER").unwrap();
2000 config.password_env_name = "PHAROS_DB_PARITY_PG_PASSWORD".into();
2001 }
2002 "mysql" => {
2003 config.port = "23306".into();
2004 config.user = "root".into();
2005 config.password_env_name = "PHAROS_TEST_MYSQL_ROOT_PASSWORD".into();
2006 }
2007 "mongodb" => {
2008 config.port = "27018".into();
2009 config.options = Some("directConnection=true".into());
2010 }
2011 _ => panic!("unsupported isolated backend"),
2012 }
2013 let adapter = pharos_runtime_database_adapters::resolve(&config).unwrap();
2014 adapter.ensure_database(&config, &contract.schema).unwrap();
2015 adapter.apply_contract(&config, &contract).unwrap();
2016 let store = super::super::database::LogicalAppStore {
2017 config,
2018 schema: contract.schema,
2019 adapter,
2020 };
2021 store
2024 .adapter
2025 .transact(
2026 &store.config,
2027 &store.schema,
2028 &Transaction {
2029 steps: vec![Mutation::Update {
2030 entity: "artists".into(),
2031 filter: Predicate::Equal("crm_status".into(), json!("active")),
2032 values: literals(json!({"crm_status":"inactive"})),
2033 expected_rows: None,
2034 }],
2035 idempotency_key: None,
2036 },
2037 )
2038 .unwrap();
2039 let mut fields = Map::new();
2040 for field in value["schema"]["entities"]
2041 .as_array()
2042 .unwrap()
2043 .iter()
2044 .find(|e| e["name"] == "artists")
2045 .unwrap()["fields"]
2046 .as_array()
2047 .unwrap()
2048 {
2049 let name = field["name"].as_str().unwrap();
2050 if name == "csv_row_id" || !field["default"].is_null() {
2051 continue;
2052 }
2053 fields.insert(
2054 name.into(),
2055 if field["kind"] == "integer" {
2056 json!(1)
2057 } else if field["nullable"] == true {
2058 Value::Null
2059 } else {
2060 json!("")
2061 },
2062 );
2063 }
2064 fields.insert("name".into(), json!("Mad Maxx"));
2065 fields.insert("website".into(), json!("https://artist.example/events"));
2066 store
2067 .adapter
2068 .transact(
2069 &store.config,
2070 &store.schema,
2071 &Transaction {
2072 steps: vec![Mutation::Insert {
2073 entity: "artists".into(),
2074 values: literals(Value::Object(fields)),
2075 }],
2076 idempotency_key: None,
2077 },
2078 )
2079 .unwrap();
2080 let event = json!({"@type":"MusicEvent","name":"Recorded test performance","startDate":pharos_runtime_core::time::timestamp_after_seconds_utc(86400),"location":{"name":"Recorded venue"},"performer":{"name":"Mad Maxx"},"url":"https://events.example/performance","offers":{"url":"https://tickets.example/performance"}});
2081 let run = |event: &Value| {
2082 store
2083 .adapter
2084 .transact(
2085 &store.config,
2086 &store.schema,
2087 &Transaction {
2088 steps: vec![Mutation::Update {
2089 entity: "event_source".into(),
2090 filter: Predicate::Equal("enabled".into(), json!(true)),
2091 values: literals(json!({"next_check_at":"2020-01-01T00:00:00Z"})),
2092 expected_rows: None,
2093 }],
2094 idempotency_key: None,
2095 },
2096 )
2097 .unwrap();
2098 process_store_source(&store, |_| {
2099 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2100 status_code: 200,
2101 body: event.to_string(),
2102 })
2103 });
2104 };
2105 run(&event);
2107 run(&event);
2108 let source_state = query(
2109 &store,
2110 "event_source",
2111 &[
2112 "last_status",
2113 "last_message",
2114 "enabled",
2115 "url",
2116 "last_checked_at",
2117 "next_check_at",
2118 ],
2119 Predicate::Equal("id".into(), json!(1)),
2120 );
2121 let state = source_state.as_ref().unwrap();
2122 let checked =
2123 runtime_timestamp_seconds(state["last_checked_at"].as_str().unwrap()).unwrap();
2124 let next = runtime_timestamp_seconds(state["next_check_at"].as_str().unwrap()).unwrap();
2125 assert!(
2126 (43_100..=43_300).contains(&(next - checked)),
2127 "discovery must run twice daily"
2128 );
2129 let gig = query(
2130 &store,
2131 "gig_event",
2132 &["event_key", "title", "status"],
2133 Predicate::Equal("title".into(), json!("Recorded test performance")),
2134 )
2135 .unwrap_or_else(|| panic!("missing accepted gig; source: {source_state:?}"));
2136 let count = store
2137 .adapter
2138 .query(
2139 &store.config,
2140 &store.schema,
2141 &Query {
2142 entity: "gig_event".into(),
2143 fields: vec!["id".into()],
2144 filter: None,
2145 order: vec![],
2146 limit: 10,
2147 offset: 0,
2148 },
2149 )
2150 .unwrap();
2151 assert_eq!(count.len(), 1);
2152 let artist_fields = store
2154 .schema
2155 .entities
2156 .iter()
2157 .find(|entity| entity.name == "artists")
2158 .unwrap()
2159 .fields
2160 .iter()
2161 .map(|field| field.name.as_str())
2162 .collect::<Vec<_>>();
2163 let mut second = query(
2164 &store,
2165 "artists",
2166 &artist_fields,
2167 Predicate::All(vec![
2168 Predicate::Equal("name".into(), json!("Mad Maxx")),
2169 Predicate::Equal("crm_status".into(), json!("active")),
2170 ]),
2171 )
2172 .unwrap();
2173 second.remove("csv_row_id");
2174 second.insert(
2175 "id".into(),
2176 if second.get("id").is_some_and(Value::is_string) {
2177 json!("controlled-secondary-artist")
2178 } else {
2179 json!(1000000)
2180 },
2181 );
2182 second.insert("name".into(), json!("Mad Tribe"));
2183 second.insert(
2184 "website".into(),
2185 json!("https://otherartist.example/events"),
2186 );
2187 store
2188 .adapter
2189 .transact(
2190 &store.config,
2191 &store.schema,
2192 &Transaction {
2193 steps: vec![
2194 Mutation::Insert {
2195 entity: "artists".into(),
2196 values: literals(Value::Object(second)),
2197 },
2198 Mutation::Update {
2199 entity: "event_source".into(),
2200 filter: Predicate::Equal("id".into(), json!(1)),
2201 values: literals(json!({"enabled":false})),
2202 expected_rows: Some(1),
2203 },
2204 ],
2205 idempotency_key: None,
2206 },
2207 )
2208 .unwrap();
2209 let mut shared_event = event.clone();
2210 shared_event["performer"] = json!([{"name":"Mad Maxx"},{"name":"Mad Tribe"}]);
2211 for _ in 0..2 {
2212 process_store_source(&store, |_| {
2213 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2214 status_code: 200,
2215 body: shared_event.to_string(),
2216 })
2217 });
2218 }
2219 let links = store
2220 .adapter
2221 .query(
2222 &store.config,
2223 &store.schema,
2224 &Query {
2225 entity: "gig_artist".into(),
2226 fields: vec!["artist_row_id".into()],
2227 filter: Some(Predicate::Equal(
2228 "event_key".into(),
2229 gig["event_key"].clone(),
2230 )),
2231 order: vec![],
2232 limit: 10,
2233 offset: 0,
2234 },
2235 )
2236 .unwrap();
2237 assert_eq!(links.len(), 2);
2238 store
2239 .adapter
2240 .transact(
2241 &store.config,
2242 &store.schema,
2243 &Transaction {
2244 steps: vec![
2245 Mutation::Update {
2246 entity: "event_source".into(),
2247 filter: Predicate::Equal(
2248 "url".into(),
2249 json!("https://otherartist.example/events"),
2250 ),
2251 values: literals(json!({"enabled":false})),
2252 expected_rows: Some(1),
2253 },
2254 Mutation::Update {
2255 entity: "event_source".into(),
2256 filter: Predicate::Equal("id".into(), json!(1)),
2257 values: literals(json!({"enabled":true})),
2258 expected_rows: Some(1),
2259 },
2260 ],
2261 idempotency_key: None,
2262 },
2263 )
2264 .unwrap();
2265 let mut rescheduled = event.clone();
2266 rescheduled["startDate"] = json!(pharos_runtime_core::time::timestamp_after_seconds_utc(
2267 172800
2268 ));
2269 rescheduled["eventStatus"] = json!("https://schema.org/EventRescheduled");
2270 run(&rescheduled);
2271 assert_eq!(
2272 runtime_timestamp_seconds(
2273 query(
2274 &store,
2275 "gig_event",
2276 &["starts_at"],
2277 Predicate::Equal("event_key".into(), gig["event_key"].clone())
2278 )
2279 .unwrap()["starts_at"]
2280 .as_str()
2281 .unwrap()
2282 ),
2283 runtime_timestamp_seconds(rescheduled["startDate"].as_str().unwrap())
2284 );
2285 let last_success = query(
2287 &store,
2288 "event_source",
2289 &["last_success_at"],
2290 Predicate::Equal("id".into(), json!(1)),
2291 )
2292 .unwrap()["last_success_at"]
2293 .clone();
2294 assert!(!last_success.as_str().unwrap().is_empty());
2295 for (response, status) in [
2296 (
2297 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2298 status_code: 200,
2299 body: "<html><title>Sign in</title><input type=\"password\"></html>".into(),
2300 }),
2301 "authorization_required",
2302 ),
2303 (
2304 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2305 status_code: 429,
2306 body: String::new(),
2307 }),
2308 "rate_limited",
2309 ),
2310 (
2311 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2312 status_code: 403,
2313 body: String::new(),
2314 }),
2315 "authorization_required",
2316 ),
2317 (
2318 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2319 status_code: 503,
2320 body: String::new(),
2321 }),
2322 "failed",
2323 ),
2324 (
2325 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2326 status_code: 200,
2327 body: "x".repeat(1024 * 1024 + 1),
2328 }),
2329 "failed",
2330 ),
2331 (
2332 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2333 status_code: 404,
2334 body: String::new(),
2335 }),
2336 "not_found",
2337 ),
2338 (
2339 Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2340 status_code: 410,
2341 body: String::new(),
2342 }),
2343 "not_found",
2344 ),
2345 (Err(SourceReadError::AccessBlocked), "access_blocked"),
2346 ] {
2347 store
2348 .adapter
2349 .transact(
2350 &store.config,
2351 &store.schema,
2352 &Transaction {
2353 steps: vec![Mutation::Update {
2354 entity: "event_source".into(),
2355 filter: Predicate::Equal("id".into(), json!(1)),
2356 values: literals(json!({"next_check_at":"2020-01-01T00:00:00Z"})),
2357 expected_rows: Some(1),
2358 }],
2359 idempotency_key: None,
2360 },
2361 )
2362 .unwrap();
2363 process_store_source(&store, |_| response.clone());
2364 assert_eq!(
2365 query(
2366 &store,
2367 "event_source",
2368 &["last_success_at"],
2369 Predicate::Equal("id".into(), json!(1))
2370 )
2371 .unwrap()["last_success_at"],
2372 last_success
2373 );
2374 assert_eq!(
2375 query(
2376 &store,
2377 "event_source",
2378 &["last_status"],
2379 Predicate::Equal("id".into(), json!(1))
2380 )
2381 .unwrap()["last_status"],
2382 status
2383 );
2384 assert_eq!(
2385 query(
2386 &store,
2387 "gig_event",
2388 &["title"],
2389 Predicate::Equal("event_key".into(), gig["event_key"].clone())
2390 )
2391 .unwrap()["title"],
2392 event["name"]
2393 );
2394 }
2395 let reopened = super::super::database::LogicalAppStore {
2397 adapter: pharos_runtime_database_adapters::resolve(&store.config).unwrap(),
2398 config: store.config.clone(),
2399 schema: store.schema.clone(),
2400 };
2401 assert!(
2402 query(
2403 &reopened,
2404 "gig_event",
2405 &["event_key"],
2406 Predicate::Equal("event_key".into(), gig["event_key"].clone())
2407 )
2408 .is_some()
2409 );
2410 let mut cancelled = event.clone();
2411 cancelled["eventStatus"] = json!("https://schema.org/EventCancelled");
2412 run(&cancelled);
2413 assert_eq!(
2414 query(
2415 &store,
2416 "gig_event",
2417 &["status"],
2418 Predicate::Equal("event_key".into(), gig["event_key"].clone())
2419 )
2420 .unwrap()["status"],
2421 "cancelled"
2422 );
2423 store
2424 .adapter
2425 .transact(
2426 &store.config,
2427 &store.schema,
2428 &Transaction {
2429 steps: vec![Mutation::Update {
2430 entity: "gig_event".into(),
2431 filter: Predicate::Equal("event_key".into(), gig["event_key"].clone()),
2432 values: literals(json!({"manual_locked":true,"title":"Owner correction"})),
2433 expected_rows: Some(1),
2434 }],
2435 idempotency_key: None,
2436 },
2437 )
2438 .unwrap();
2439 run(&event);
2440 assert_eq!(
2441 query(
2442 &store,
2443 "gig_event",
2444 &["title"],
2445 Predicate::Equal("event_key".into(), gig["event_key"].clone())
2446 )
2447 .unwrap()["title"],
2448 "Owner correction"
2449 );
2450 for (key, candidate, status) in [
2452 (
2453 "old-post",
2454 json!({"evidence_kind":"rendered_post","datePublished":"Dec 30 2024","description":"Playing October 30 2026"}),
2455 "review",
2456 ),
2457 (
2458 "retrospective",
2459 json!({"evidence_kind":"rendered_post","datePublished":current_timestamp_utc(),"description":"Thank you for the festival"}),
2460 "review",
2461 ),
2462 (
2463 "stream",
2464 json!({"source_profile":"https://facebook.com/artist","description":"private-stream-text"}),
2465 "review",
2466 ),
2467 (
2468 "owner-decision",
2469 json!({"evidence_kind":"rendered_post","description":"owner-rejected-evidence"}),
2470 "rejected",
2471 ),
2472 ] {
2473 store.adapter.transact(&store.config,&store.schema,&Transaction {steps:vec![Mutation::Insert{entity:"event_observation".into(),values:literals(json!({"source_id":1,"event_key":key,"evidence_hash":key,"source_url":"https://facebook.com/artist","observed_at":current_timestamp_utc(),"facts":json!({"candidate":candidate}).to_string(),"review_status":status}))}],idempotency_key:None}).unwrap();
2474 }
2475 prune_social_reviews(
2476 &store,
2477 runtime_timestamp_seconds(¤t_timestamp_utc()).unwrap(),
2478 )
2479 .unwrap();
2480 for key in ["old-post", "retrospective", "stream"] {
2481 let row = query(
2482 &store,
2483 "event_observation",
2484 &["facts", "review_status"],
2485 Predicate::Equal("event_key".into(), json!(key)),
2486 )
2487 .unwrap();
2488 assert_eq!(row["review_status"], "rejected");
2489 assert!(
2490 !row["facts"]
2491 .as_str()
2492 .unwrap()
2493 .contains("private-stream-text")
2494 );
2495 }
2496 assert!(
2497 query(
2498 &store,
2499 "event_observation",
2500 &["facts"],
2501 Predicate::Equal("event_key".into(), json!("owner-decision"))
2502 )
2503 .unwrap()["facts"]
2504 .as_str()
2505 .unwrap()
2506 .contains("owner-rejected-evidence")
2507 );
2508 prune_social_reviews(
2509 &store,
2510 runtime_timestamp_seconds(¤t_timestamp_utc()).unwrap(),
2511 )
2512 .unwrap();
2513 assert!(
2515 store
2516 .schema
2517 .entities
2518 .iter()
2519 .any(|entity| entity.name == "event_observation"
2520 && entity
2521 .fields
2522 .iter()
2523 .any(|field| field.name == "review_status")),
2524 "review schema required for this acceptance case"
2525 );
2526 let mut incomplete = event.clone();
2527 incomplete.as_object_mut().unwrap().remove("startDate");
2528 run(&incomplete);
2529 let candidate = query(
2530 &store,
2531 "event_observation",
2532 &["id", "facts", "review_status"],
2533 Predicate::Equal("review_status".into(), json!("review")),
2534 )
2535 .unwrap();
2536 assert!(
2537 candidate["facts"]
2538 .as_str()
2539 .unwrap()
2540 .contains("extraction_version")
2541 );
2542 store
2543 .adapter
2544 .transact(
2545 &store.config,
2546 &store.schema,
2547 &Transaction {
2548 steps: vec![Mutation::Update {
2549 entity: "event_observation".into(),
2550 filter: Predicate::Equal("id".into(), candidate["id"].clone()),
2551 values: literals(json!({"review_status":"rejected"})),
2552 expected_rows: Some(1),
2553 }],
2554 idempotency_key: None,
2555 },
2556 )
2557 .unwrap();
2558 run(&incomplete);
2559 assert_eq!(
2560 query(
2561 &store,
2562 "event_observation",
2563 &["review_status"],
2564 Predicate::Equal("id".into(), candidate["id"].clone())
2565 )
2566 .unwrap()["review_status"],
2567 "rejected"
2568 );
2569 assert_eq!(
2570 query(
2571 &store,
2572 "event_source",
2573 &["last_status"],
2574 Predicate::Equal("id".into(), json!(1))
2575 )
2576 .unwrap()["last_status"],
2577 "reviewed"
2578 );
2579 assert!(
2580 query(
2581 &store,
2582 "event_observation",
2583 &["id"],
2584 Predicate::Equal("review_status".into(), json!("review"))
2585 )
2586 .is_none()
2587 );
2588 let source_artist = query(
2590 &store,
2591 "event_source",
2592 &["artist_row_id"],
2593 Predicate::Equal("id".into(), json!(1)),
2594 )
2595 .unwrap();
2596 let artist = query(
2597 &store,
2598 "artists",
2599 &["csv_row_id", "record_updated_at"],
2600 Predicate::Equal("csv_row_id".into(), source_artist["artist_row_id"].clone()),
2601 )
2602 .unwrap();
2603 let artist_id = artist["csv_row_id"].as_i64().unwrap();
2604 let scope = pharos_runtime_social::connection::ConnectionScope::new(
2605 &store.schema.app_id,
2606 "controlled_social",
2607 7,
2608 )
2609 .unwrap();
2610 let scope_filter = pharos_runtime_social::repository::scope_filter(
2611 &scope,
2612 pharos_runtime_social::provider::SocialProvider::X,
2613 );
2614 store.adapter.transact(&store.config,&store.schema,&Transaction {steps:vec![Mutation::Insert {entity:"social_connection".into(),values:literals(json!({"application":scope.application(),"namespace":scope.namespace(),"owner":7,"provider":"x","account_id":"123","username":"controlled","registration":"r1","revision":1,"status":"connected","expires":2000000000,"credential_ref":"controlled-reference","lease_token":"","lease_until":0}))}],idempotency_key:None}).unwrap();
2615 let batch = || super::super::social_connections::ObservationBatch {
2616 unverified_posts: 0,
2617 verified_posts: 0,
2618 evidence: Vec::new(),
2619 links: vec![
2620 "https://artist.example/from-social".into(),
2621 "https://artist.example/from-social".into(),
2622 "https://x.com/another-profile".into(),
2623 "https://www.bing.com/search?q=artist".into(),
2624 "https://linktr.ee/artist".into(),
2625 "https://unrelated.example/event".into(),
2626 ],
2627 guards: vec![Mutation::RequireCount {
2628 entity: "social_connection".into(),
2629 filter: Predicate::All(vec![
2630 scope_filter.clone(),
2631 Predicate::Equal("revision".into(), json!(1)),
2632 Predicate::Equal("status".into(), json!("connected")),
2633 ]),
2634 minimum: 1,
2635 maximum: Some(1),
2636 }],
2637 };
2638 let source_guard = Predicate::All(vec![
2639 Predicate::Equal("id".into(), json!(1)),
2640 Predicate::Equal("enabled".into(), json!(true)),
2641 ]);
2642 for _ in 0..2 {
2643 assert_eq!(
2644 accept_social_links(
2645 &store,
2646 artist_id,
2647 source_guard.clone(),
2648 artist["record_updated_at"].clone(),
2649 batch()
2650 )
2651 .unwrap(),
2652 1
2653 );
2654 }
2655 let public_sources = store
2656 .adapter
2657 .query(
2658 &store.config,
2659 &store.schema,
2660 &Query {
2661 entity: "event_source".into(),
2662 fields: vec!["id".into()],
2663 filter: Some(Predicate::Equal(
2664 "url".into(),
2665 json!("https://artist.example/from-social"),
2666 )),
2667 order: vec![],
2668 limit: 10,
2669 offset: 0,
2670 },
2671 )
2672 .unwrap();
2673 assert_eq!(public_sources.len(), 1);
2674 store
2675 .adapter
2676 .transact(
2677 &store.config,
2678 &store.schema,
2679 &Transaction {
2680 steps: vec![Mutation::Update {
2681 entity: "social_connection".into(),
2682 filter: scope_filter.clone(),
2683 values: literals(json!({"status":"disconnected","revision":2})),
2684 expected_rows: Some(1),
2685 }],
2686 idempotency_key: None,
2687 },
2688 )
2689 .unwrap();
2690 assert!(
2691 accept_social_links(
2692 &store,
2693 artist_id,
2694 source_guard,
2695 artist["record_updated_at"].clone(),
2696 batch()
2697 )
2698 .is_err()
2699 );
2700 for name in ["First organizer", "Second organizer"] {
2702 store
2703 .adapter
2704 .transact(
2705 &store.config,
2706 &store.schema,
2707 &Transaction {
2708 steps: vec![Mutation::Insert {
2709 entity: "organizer".into(),
2710 values: literals(json!({"name":name})),
2711 }],
2712 idempotency_key: None,
2713 },
2714 )
2715 .unwrap();
2716 }
2717 let page = Value::Array(
2718 (0..100)
2719 .map(|index| {
2720 json!({
2721 "@type":"MusicEvent", "@id":format!("https://events.example/batch/{index}"),
2722 "name":format!("Batch performance {index}"),
2723 "startDate":pharos_runtime_core::time::timestamp_after_seconds_utc(172800),
2724 "location":{"name":"Batch venue"},
2725 "performer":[{"name":"Mad Maxx"},{"name":"Mad Tribe"}],
2726 "organizer":[{"name":"First organizer"},{"name":"Second organizer"}],
2727 "url":format!("https://events.example/batch/{index}")
2728 })
2729 })
2730 .collect(),
2731 );
2732 run(&page);
2733 run(&page);
2734 let batch_events = store
2735 .adapter
2736 .query(
2737 &store.config,
2738 &store.schema,
2739 &Query {
2740 entity: "gig_event".into(),
2741 fields: vec!["event_key".into()],
2742 filter: Some(Predicate::Contains(
2743 "title".into(),
2744 "Batch performance".into(),
2745 )),
2746 order: vec![],
2747 limit: 1000,
2748 offset: 0,
2749 },
2750 )
2751 .unwrap();
2752 assert_eq!(batch_events.len(), 100);
2753 let links = store
2754 .adapter
2755 .query(
2756 &store.config,
2757 &store.schema,
2758 &Query {
2759 entity: "gig_organizer".into(),
2760 fields: vec!["event_key".into()],
2761 filter: None,
2762 order: vec![],
2763 limit: 1000,
2764 offset: 0,
2765 },
2766 )
2767 .unwrap();
2768 assert_eq!(links.len(), 200);
2769 store
2771 .adapter
2772 .transact(
2773 &store.config,
2774 &store.schema,
2775 &Transaction {
2776 steps: vec![Mutation::Update {
2777 entity: "event_source".into(),
2778 filter: Predicate::Equal("enabled".into(), json!(true)),
2779 values: literals(json!({"enabled":false})),
2780 expected_rows: Some(2),
2781 }],
2782 idempotency_key: None,
2783 },
2784 )
2785 .unwrap();
2786 process_store_source(&store, |_| panic!("paused source fetched"));
2787 let mut invalid_steps = vec![Mutation::Update {
2788 entity: "artists".into(),
2789 filter: Predicate::Equal("csv_row_id".into(), json!(artist_id)),
2790 values: literals(json!({"source_url":"https://label.example/artists/artist"})),
2791 expected_rows: Some(1),
2792 }];
2793 for url in [
2794 "https://www.bing.com/search?q=artist",
2795 "https://linktr.ee/artist",
2796 "https://label.example/artists/artist",
2797 ] {
2798 invalid_steps.push(Mutation::Insert {entity:"event_source".into(),values:literals(json!({"artist_row_id":artist_id,"url":url,"enabled":true,"managed":false,"next_check_at":"2020-01-01T00:00:00Z","last_status":"access_blocked","last_checked_at":"","last_message":""}))});
2799 }
2800 store
2801 .adapter
2802 .transact(
2803 &store.config,
2804 &store.schema,
2805 &Transaction {
2806 steps: invalid_steps,
2807 idempotency_key: None,
2808 },
2809 )
2810 .unwrap();
2811 process_store_source(&store, |_| panic!("unrelated source fetched"));
2812 let retired = store
2813 .adapter
2814 .query(
2815 &store.config,
2816 &store.schema,
2817 &Query {
2818 entity: "event_source".into(),
2819 fields: vec!["enabled".into()],
2820 filter: Some(Predicate::Equal(
2821 "last_status".into(),
2822 json!("source_removed"),
2823 )),
2824 order: vec![],
2825 limit: 1000,
2826 offset: 0,
2827 },
2828 )
2829 .unwrap();
2830 assert!(retired.len() >= 3);
2831 assert!(retired.iter().all(|row| row["enabled"] == false));
2832
2833 let now = current_timestamp_utc();
2835 let day = pharos_runtime_core::time::timestamp_after_seconds_utc(86400)[..10].to_owned();
2836 let post = json!({"evidence_kind":"rendered_post","audience":"public","description":format!("Next stop: Schema regression festival {day}"),"datePublished":now,"author_url":"https://www.facebook.com/artist","source_profile":"https://www.facebook.com/artist","url":"https://www.facebook.com/artist/posts/123"});
2837 let event = social_announcements::extract(
2838 &post,
2839 "Mad Maxx",
2840 runtime_timestamp_seconds(&now).unwrap(),
2841 )
2842 .unwrap();
2843 let mut legacy = event.clone();
2844 legacy.key =
2845 pharos_runtime_database::operations::operation_fingerprint(&"legacy-announcement")
2846 .unwrap();
2847 legacy.event_url = format!("{}?tracking=legacy", event.event_url);
2848 let values = model::write_values(None, &legacy, artist_id, 1, &now, None).unwrap();
2849 for _ in 0..2 {
2850 store
2851 .adapter
2852 .transact(
2853 &store.config,
2854 &store.schema,
2855 &Transaction {
2856 steps: vec![Mutation::Upsert {
2857 entity: "gig_event".into(),
2858 unique_key: vec!["event_key".into()],
2859 values: literals(Value::Object(values.clone())),
2860 }],
2861 idempotency_key: None,
2862 },
2863 )
2864 .unwrap();
2865 }
2866 let mut rediscovered = vec![event];
2867 social_announcements::retain_existing_identities(&store, artist_id, &mut rediscovered)
2868 .unwrap();
2869 let event = rediscovered.remove(0);
2870 assert_eq!(event.key, legacy.key);
2871 let rows = store
2872 .adapter
2873 .query(
2874 &store.config,
2875 &store.schema,
2876 &Query {
2877 entity: "gig_event".into(),
2878 fields: vec!["starts_at".into(), "venue".into(), "timezone".into()],
2879 filter: Some(Predicate::Equal("event_key".into(), json!(event.key))),
2880 order: vec![],
2881 limit: 2,
2882 offset: 0,
2883 },
2884 )
2885 .unwrap();
2886 assert_eq!(rows.len(), 1);
2887 assert!(rows[0]["starts_at"].as_str().unwrap().starts_with(&day));
2888 assert_eq!(rows[0]["venue"], "");
2889 assert_eq!(rows[0]["timezone"], "");
2890
2891 store
2892 .adapter
2893 .verify_contract(
2894 &store.config,
2895 &pharos_runtime_database::contract::AppDataContract {
2896 schema: store.schema.clone(),
2897 ..contract
2898 },
2899 )
2900 .unwrap();
2901 drop(store);
2902 if db.is_file() {
2903 std::fs::remove_file(db).unwrap();
2904 }
2905 }
2906
2907 #[test]
2909 fn event_identity_conflicts_and_cancellation_are_guarded() {
2911 let now = runtime_timestamp_seconds("2026-10-20T00:00:00Z").unwrap();
2912 let mut input = json!({"@type":"MusicEvent","@id":"https://events.example/id/1","name":"Night","startDate":"2027-01-01T20:00:00+01:00","location":{"name":"Hall","address":{"streetAddress":"Main 1","addressCountry":"France"}},"performer":{"name":"Mad Maxx"},"organizer":{"name":"Local Promoter"},"url":"https://events.example/night"});
2913 let parse = |value: &Value| {
2914 extract_events(
2915 &value.to_string(),
2916 "Mad Maxx",
2917 "https://artist.example/events",
2918 now,
2919 )
2920 .remove(0)
2921 };
2922 let first = parse(&input);
2923 assert_eq!(first.country, "France");
2924 assert_eq!(first.address, "Main 1");
2925 assert_eq!(first.timezone, "+01:00");
2926 assert_eq!(first.organizers, ["Local Promoter"]);
2927 let accepted =
2928 model::write_values(None, &first, 1, 10, "2026-10-20T00:00:00Z", None).unwrap();
2929 input["startDate"] = json!("2027-01-02T20:00:00+01:00");
2930 input["url"] = json!("https://events.example/new-url");
2931 let changed = parse(&input);
2932 assert_eq!(first.key, changed.key);
2933 assert!(model::conflicts(&accepted, &changed, 11));
2934 assert!(!model::conflicts(&accepted, &changed, 10));
2935 input["@id"] = json!("https://events.example/id/2");
2936 assert_ne!(first.key, parse(&input).key);
2937 let mut cancelled = accepted;
2938 cancelled.insert("status".into(), json!("cancelled"));
2939 cancelled.insert("event_state".into(), json!("cancelled"));
2940 assert!(model::conflicts(&cancelled, &changed, 10));
2941 let retained = model::write_values(
2942 Some(&cancelled),
2943 &first,
2944 2,
2945 11,
2946 "2026-10-20T00:00:00Z",
2947 None,
2948 )
2949 .unwrap();
2950 assert_eq!(retained["status"], "cancelled");
2951 assert_eq!(retained["artist_row_id"], 1);
2952 assert_eq!(retained["source_id"], 10);
2953 assert_ne!(
2954 retained["record_updated_at"],
2955 cancelled["record_updated_at"]
2956 );
2957 input["endDate"] = json!("2027-01-01T00:00:00Z");
2958 assert!(
2959 extract_events(
2960 &input.to_string(),
2961 "Mad Maxx",
2962 "https://artist.example/events",
2963 now
2964 )
2965 .is_empty()
2966 );
2967 }
2968
2969 #[test]
2971 fn recent_performances_share_verified_event_validation() {
2972 let now = runtime_timestamp_seconds("2026-10-23T12:00:00Z").unwrap();
2973 let mut event = json!({"@type":"MusicEvent","name":"Recent set","startDate":"2026-10-01T20:00:00Z","location":{"name":"Hall"},"performer":{"name":"Shota"},"url":"https://artist.example/event"});
2974 assert!(event_from_value(&event, "Shota", "https://artist.example/", now).is_some());
2975 event["startDate"] = json!("2026-09-23T11:59:59Z");
2976 assert!(event_from_value(&event, "Shota", "https://artist.example/", now).is_none());
2977 event["startDate"] = json!("2026-09-23T12:00:00Z");
2978 assert!(event_from_value(&event, "Shota", "https://artist.example/", now).is_some());
2979 assert!(
2980 event_from_value(&event, "Another artist", "https://artist.example/", now).is_none()
2981 );
2982 }
2983
2984 #[test]
2985 fn structured_events_require_verified_identity_time_and_venue() {
2986 let event = json!({"@type":"MusicEvent","name":"Night","startDate":"2027-01-01T20:00:00+01:00","location":{"name":"Hall"},"performer":{"name":"Mad Maxx"},"url":"https://events.example/night","offers":{"url":"https://tickets.example/night"}});
2987 let now = runtime_timestamp_seconds("2026-10-20T00:00:00Z").unwrap();
2988 let events = extract_events(
2989 &event.to_string(),
2990 "MAD MAXX",
2991 "https://artist.example/events",
2992 now,
2993 );
2994 assert_eq!(events.len(), 1);
2995 assert_eq!(
2996 extract_events(
2997 &format!("<SCRIPT TYPE='application/ld+json'>{event}</SCRIPT>"),
2998 "Mad Maxx",
2999 "https://artist.example/events",
3000 now
3001 )
3002 .len(),
3003 1
3004 );
3005 assert!(events[0].starts_at.starts_with("2027-01-01T19:00:00"));
3006 assert!(
3007 extract_events(
3008 &event.to_string(),
3009 "Max",
3010 "https://artist.example/events",
3011 now
3012 )
3013 .is_empty()
3014 );
3015 let mut incomplete = event.clone();
3016 incomplete["startDate"] = json!("2027-01-01");
3017 assert!(
3018 extract_events(
3019 &incomplete.to_string(),
3020 "Mad Maxx",
3021 "https://artist.example/events",
3022 now
3023 )
3024 .is_empty()
3025 );
3026 assert!(public_link("javascript:alert(1)").is_none());
3027 assert!(public_link("https://user:secret@example.com/").is_none());
3028 }
3029}