1use axum::{
11 Json,
12 extract::{Path, Query, State},
13 http::StatusCode,
14};
15use uuid::Uuid;
16
17use cloudillo_core::{
18 IdTag,
19 extract::{Auth, OptionalRequestId},
20 prelude::*,
21};
22use cloudillo_types::{
23 meta_adapter::{ListContactOptions, Profile, UpdateAddressBookData},
24 types::ApiResponse,
25};
26
27use crate::{
28 profile_overlay::{build_overlay, merge_profile_into_input, resolve_profile},
29 types::{
30 AddressBookCreate, AddressBookOutput, AddressBookPatch, ContactInput, ContactListItem,
31 ContactOutput, ContactPatch, ImportConflictMode, ImportContactsError, ImportContactsQuery,
32 ImportContactsResult, ListContactsQuery, ProfileOverlay,
33 },
34 vcard,
35};
36
37fn ab_to_output(ab: &cloudillo_types::meta_adapter::AddressBook) -> AddressBookOutput {
41 AddressBookOutput {
42 ab_id: ab.ab_id,
43 name: ab.name.to_string(),
44 description: ab.description.as_deref().map(str::to_string),
45 ctag: ab.ctag.to_string(),
46 created_at: ab.created_at,
47 updated_at: ab.updated_at,
48 }
49}
50
51async fn resolve_overlays(
56 app: &App,
57 tn_id: TnId,
58 id_tags: impl IntoIterator<Item = &str>,
59) -> ClResult<std::collections::HashMap<String, ProfileOverlay>> {
60 let mut map = std::collections::HashMap::new();
61 let mut seen = std::collections::HashSet::new();
62 for id_tag in id_tags {
63 if !seen.insert(id_tag.to_string()) {
64 continue;
65 }
66 if let Some(profile) = resolve_profile(app, tn_id, id_tag).await? {
67 map.insert(id_tag.to_string(), build_overlay(&profile));
68 }
69 }
70 Ok(map)
71}
72
73fn contact_row_to_output(
74 row: &cloudillo_types::meta_adapter::Contact,
75 overlays: &std::collections::HashMap<String, ProfileOverlay>,
76) -> ContactOutput {
77 let (parsed, parse_error) = if let Some((p, _, warnings)) = vcard::parse(&row.vcard) {
80 let joined = if warnings.is_empty() { None } else { Some(warnings.join("; ")) };
81 (p, joined)
82 } else {
83 error!(
84 c_id = row.c_id,
85 ab_id = row.ab_id,
86 uid = %row.uid,
87 "stored vCard blob is unparseable — persisted corruption, returning empty projection",
88 );
89 (crate::types::ContactInput::default(), Some("unparseable stored vCard".to_string()))
90 };
91 let profile_id_tag = row.extracted.profile_id_tag.as_deref().map(str::to_string);
92 let profile = profile_id_tag.as_deref().and_then(|tag| overlays.get(tag).cloned());
93
94 let mut photo = row.extracted.photo_uri.as_deref().map(str::to_string);
96 if photo.is_none()
97 && let Some(ov) = &profile
98 {
99 photo.clone_from(&ov.profile_pic);
100 }
101
102 let mut formatted_name = row.extracted.fn_name.as_deref().map(str::to_string);
103 if formatted_name.as_deref().is_none_or(str::is_empty)
104 && let Some(ov) = &profile
105 {
106 formatted_name.clone_from(&ov.name);
107 }
108
109 ContactOutput {
110 c_id: row.c_id,
111 ab_id: row.ab_id,
112 uid: row.uid.to_string(),
113 etag: row.etag.to_string(),
114 formatted_name,
115 n: parsed.n,
116 emails: parsed.emails,
117 phones: parsed.phones,
118 org: row.extracted.org.as_deref().map(str::to_string),
119 title: row.extracted.title.as_deref().map(str::to_string),
120 note: row.extracted.note.as_deref().map(str::to_string),
121 photo,
122 profile_id_tag,
123 profile,
124 parse_error,
125 created_at: row.created_at,
126 updated_at: row.updated_at,
127 }
128}
129
130fn contact_view_to_list_item(
131 row: &cloudillo_types::meta_adapter::ContactView,
132 overlays: &std::collections::HashMap<String, ProfileOverlay>,
133) -> ContactListItem {
134 let profile_id_tag = row.extracted.profile_id_tag.as_deref().map(str::to_string);
135 let profile = profile_id_tag.as_deref().and_then(|tag| overlays.get(tag).cloned());
136
137 let mut photo = row.extracted.photo_uri.as_deref().map(str::to_string);
138 if photo.is_none()
139 && let Some(ov) = &profile
140 {
141 photo.clone_from(&ov.profile_pic);
142 }
143
144 let mut formatted_name = row.extracted.fn_name.as_deref().map(str::to_string);
145 if formatted_name.as_deref().is_none_or(str::is_empty)
146 && let Some(ov) = &profile
147 {
148 formatted_name.clone_from(&ov.name);
149 }
150
151 ContactListItem {
152 c_id: row.c_id,
153 ab_id: row.ab_id,
154 uid: row.uid.to_string(),
155 etag: row.etag.to_string(),
156 formatted_name,
157 email: row.extracted.email.as_deref().map(str::to_string),
158 tel: row.extracted.tel.as_deref().map(str::to_string),
159 org: row.extracted.org.as_deref().map(str::to_string),
160 photo,
161 profile_id_tag,
162 profile,
163 updated_at: row.updated_at,
164 }
165}
166
167fn apply_contact_patch(into: &mut ContactInput, patch: ContactPatch) {
169 match patch.formatted_name {
170 Patch::Undefined => {}
171 Patch::Null => into.formatted_name = None,
172 Patch::Value(v) => into.formatted_name = Some(v),
173 }
174 match patch.n {
175 Patch::Undefined => {}
176 Patch::Null => into.n = None,
177 Patch::Value(v) => into.n = Some(v),
178 }
179 match patch.emails {
180 Patch::Undefined => {}
181 Patch::Null => into.emails.clear(),
182 Patch::Value(v) => into.emails = v,
183 }
184 match patch.phones {
185 Patch::Undefined => {}
186 Patch::Null => into.phones.clear(),
187 Patch::Value(v) => into.phones = v,
188 }
189 match patch.org {
190 Patch::Undefined => {}
191 Patch::Null => into.org = None,
192 Patch::Value(v) => into.org = Some(v),
193 }
194 match patch.title {
195 Patch::Undefined => {}
196 Patch::Null => into.title = None,
197 Patch::Value(v) => into.title = Some(v),
198 }
199 match patch.note {
200 Patch::Undefined => {}
201 Patch::Null => into.note = None,
202 Patch::Value(v) => into.note = Some(v),
203 }
204 match patch.photo {
205 Patch::Undefined => {}
206 Patch::Null => into.photo = None,
207 Patch::Value(v) => into.photo = Some(v),
208 }
209 match patch.profile_id_tag {
210 Patch::Undefined => {}
211 Patch::Null => into.profile_id_tag = None,
212 Patch::Value(v) => into.profile_id_tag = Some(v),
213 }
214}
215
216fn stored_to_input(stored: &cloudillo_types::meta_adapter::Contact) -> ClResult<ContactInput> {
220 let (mut parsed, _, _) = vcard::parse(&stored.vcard).ok_or_else(|| {
221 error!(
222 c_id = stored.c_id,
223 ab_id = stored.ab_id,
224 uid = %stored.uid,
225 "stored vCard blob is unparseable",
226 );
227 Error::Internal("stored vCard blob is unparseable".into())
228 })?;
229 parsed.uid = Some(stored.uid.to_string());
230 Ok(parsed)
231}
232
233async fn write_contact(
235 app: &App,
236 tn_id: TnId,
237 ab_id: u64,
238 mut input: ContactInput,
239) -> ClResult<ContactOutput> {
240 let uid = match input.uid.clone() {
242 Some(u) if !u.is_empty() => u,
243 _ => {
244 let u = format!("urn:uuid:{}", Uuid::new_v4());
245 input.uid = Some(u.clone());
246 u
247 }
248 };
249
250 let linked_profile: Option<Profile<Box<str>>> = match input.profile_id_tag.as_deref() {
252 Some(tag) if !tag.is_empty() => resolve_profile(app, tn_id, tag).await?,
253 _ => None,
254 };
255 merge_profile_into_input(&mut input, linked_profile.as_ref());
256
257 let now_iso = format_rev(Timestamp::now());
259 let vcard_text = vcard::generate(&input, Some(&now_iso));
260 let etag = vcard::etag_of(&vcard_text);
261 let extracted = vcard::extract_from_input(&input);
262
263 app.meta_adapter
264 .upsert_contact(tn_id, ab_id, &uid, &vcard_text, &etag, &extracted)
265 .await?;
266
267 let stored = app.meta_adapter.get_contact(tn_id, ab_id, &uid).await?.ok_or(Error::NotFound)?;
268
269 let mut overlays = std::collections::HashMap::new();
271 if let Some(profile) = linked_profile {
272 overlays.insert(profile.id_tag.to_string(), build_overlay(&profile));
273 }
274
275 Ok(contact_row_to_output(&stored, &overlays))
276}
277
278fn format_rev(ts: Timestamp) -> String {
280 chrono::DateTime::from_timestamp(ts.0, 0).map_or_else(
281 || "19700101T000000Z".to_string(),
282 |dt| dt.format("%Y%m%dT%H%M%SZ").to_string(),
283 )
284}
285
286pub async fn list_address_books(
293 State(app): State<App>,
294 tn_id: TnId,
295 IdTag(_id_tag): IdTag,
296 Auth(_auth): Auth,
297 OptionalRequestId(req_id): OptionalRequestId,
298) -> ClResult<(StatusCode, Json<ApiResponse<Vec<AddressBookOutput>>>)> {
299 let books = app.meta_adapter.list_address_books(tn_id).await?;
300 let out: Vec<AddressBookOutput> = books.iter().map(ab_to_output).collect();
301 let mut resp = ApiResponse::new(out);
302 if let Some(id) = req_id {
303 resp = resp.with_req_id(id);
304 }
305 Ok((StatusCode::OK, Json(resp)))
306}
307
308fn validate_ab_name(name: &str) -> ClResult<()> {
311 if name.is_empty() {
312 return Err(Error::ValidationError("name required".into()));
313 }
314 if name.len() > 128 {
315 return Err(Error::ValidationError("name too long".into()));
316 }
317 if name.chars().any(|c| c.is_control() || c == '/' || c == '\\') {
318 return Err(Error::ValidationError("name contains invalid character".into()));
319 }
320 Ok(())
321}
322
323pub async fn create_address_book(
324 State(app): State<App>,
325 tn_id: TnId,
326 IdTag(_id_tag): IdTag,
327 Auth(_auth): Auth,
328 OptionalRequestId(req_id): OptionalRequestId,
329 Json(body): Json<AddressBookCreate>,
330) -> ClResult<(StatusCode, Json<ApiResponse<AddressBookOutput>>)> {
331 let name = body.name.trim();
332 validate_ab_name(name)?;
333 let ab = app
334 .meta_adapter
335 .create_address_book(tn_id, name, body.description.as_deref())
336 .await?;
337 let mut resp = ApiResponse::new(ab_to_output(&ab));
338 if let Some(id) = req_id {
339 resp = resp.with_req_id(id);
340 }
341 Ok((StatusCode::CREATED, Json(resp)))
342}
343
344pub async fn patch_address_book(
345 State(app): State<App>,
346 tn_id: TnId,
347 IdTag(_id_tag): IdTag,
348 Auth(_auth): Auth,
349 OptionalRequestId(req_id): OptionalRequestId,
350 Path(ab_id): Path<u64>,
351 Json(patch): Json<AddressBookPatch>,
352) -> ClResult<(StatusCode, Json<ApiResponse<AddressBookOutput>>)> {
353 let name = match &patch.name {
354 Patch::Value(v) => Patch::Value(v.trim().to_string()),
355 Patch::Null => Patch::Null,
356 Patch::Undefined => Patch::Undefined,
357 };
358 if let Patch::Value(v) = &name {
359 validate_ab_name(v)?;
360 }
361 let update = UpdateAddressBookData { name, description: patch.description };
362 app.meta_adapter.update_address_book(tn_id, ab_id, &update).await?;
363 let ab = app.meta_adapter.get_address_book(tn_id, ab_id).await?.ok_or(Error::NotFound)?;
364 let mut resp = ApiResponse::new(ab_to_output(&ab));
365 if let Some(id) = req_id {
366 resp = resp.with_req_id(id);
367 }
368 Ok((StatusCode::OK, Json(resp)))
369}
370
371pub async fn delete_address_book(
372 State(app): State<App>,
373 tn_id: TnId,
374 IdTag(_id_tag): IdTag,
375 Auth(_auth): Auth,
376 Path(ab_id): Path<u64>,
377 OptionalRequestId(req_id): OptionalRequestId,
378) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
379 app.meta_adapter.delete_address_book(tn_id, ab_id).await?;
380 let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
381 Ok((StatusCode::OK, Json(response)))
382}
383
384async fn list_contacts_inner(
388 app: &App,
389 tn_id: TnId,
390 ab_id: Option<u64>,
391 query: ListContactsQuery,
392 req_id: Option<String>,
393) -> ClResult<(StatusCode, Json<ApiResponse<Vec<ContactListItem>>>)> {
394 let opts = ListContactOptions { q: query.q, cursor: query.cursor, limit: query.limit };
395 let mut rows = app.meta_adapter.list_contacts(tn_id, ab_id, &opts).await?;
396
397 let requested = opts.limit.unwrap_or(100).min(500) as usize;
399 let has_more = rows.len() > requested;
400 if has_more {
401 rows.truncate(requested);
402 }
403
404 let profile_tags: Vec<String> = rows
405 .iter()
406 .filter_map(|r| r.extracted.profile_id_tag.as_deref().map(str::to_string))
407 .collect();
408 let overlays = resolve_overlays(app, tn_id, profile_tags.iter().map(String::as_str)).await?;
409
410 let items: Vec<ContactListItem> =
411 rows.iter().map(|row| contact_view_to_list_item(row, &overlays)).collect();
412
413 let next_cursor = if has_more { items.last().map(|last| last.c_id.to_string()) } else { None };
414
415 let mut resp = ApiResponse::with_cursor_pagination(items, next_cursor, has_more);
416 if let Some(id) = req_id {
417 resp = resp.with_req_id(id);
418 }
419 Ok((StatusCode::OK, Json(resp)))
420}
421
422pub async fn list_contacts(
423 State(app): State<App>,
424 tn_id: TnId,
425 IdTag(_id_tag): IdTag,
426 Auth(_auth): Auth,
427 OptionalRequestId(req_id): OptionalRequestId,
428 Path(ab_id): Path<u64>,
429 Query(query): Query<ListContactsQuery>,
430) -> ClResult<(StatusCode, Json<ApiResponse<Vec<ContactListItem>>>)> {
431 list_contacts_inner(&app, tn_id, Some(ab_id), query, req_id).await
432}
433
434pub async fn list_all_contacts(
435 State(app): State<App>,
436 tn_id: TnId,
437 IdTag(_id_tag): IdTag,
438 Auth(_auth): Auth,
439 OptionalRequestId(req_id): OptionalRequestId,
440 Query(query): Query<ListContactsQuery>,
441) -> ClResult<(StatusCode, Json<ApiResponse<Vec<ContactListItem>>>)> {
442 list_contacts_inner(&app, tn_id, None, query, req_id).await
443}
444
445pub async fn get_contact(
446 State(app): State<App>,
447 tn_id: TnId,
448 IdTag(_id_tag): IdTag,
449 Auth(_auth): Auth,
450 OptionalRequestId(req_id): OptionalRequestId,
451 Path((ab_id, uid)): Path<(u64, String)>,
452) -> ClResult<(StatusCode, Json<ApiResponse<ContactOutput>>)> {
453 let stored = app.meta_adapter.get_contact(tn_id, ab_id, &uid).await?.ok_or(Error::NotFound)?;
454
455 let overlays =
456 resolve_overlays(&app, tn_id, stored.extracted.profile_id_tag.as_deref()).await?;
457 let out = contact_row_to_output(&stored, &overlays);
458
459 let mut resp = ApiResponse::new(out);
460 if let Some(id) = req_id {
461 resp = resp.with_req_id(id);
462 }
463 Ok((StatusCode::OK, Json(resp)))
464}
465
466pub async fn create_contact(
467 State(app): State<App>,
468 tn_id: TnId,
469 IdTag(_id_tag): IdTag,
470 Auth(_auth): Auth,
471 OptionalRequestId(req_id): OptionalRequestId,
472 Path(ab_id): Path<u64>,
473 Json(body): Json<ContactInput>,
474) -> ClResult<(StatusCode, Json<ApiResponse<ContactOutput>>)> {
475 app.meta_adapter.get_address_book(tn_id, ab_id).await?.ok_or(Error::NotFound)?;
477 let out = write_contact(&app, tn_id, ab_id, body).await?;
478 let mut resp = ApiResponse::new(out);
479 if let Some(id) = req_id {
480 resp = resp.with_req_id(id);
481 }
482 Ok((StatusCode::CREATED, Json(resp)))
483}
484
485pub async fn put_contact(
486 State(app): State<App>,
487 tn_id: TnId,
488 IdTag(_id_tag): IdTag,
489 Auth(_auth): Auth,
490 OptionalRequestId(req_id): OptionalRequestId,
491 Path((ab_id, uid)): Path<(u64, String)>,
492 Json(mut body): Json<ContactInput>,
493) -> ClResult<(StatusCode, Json<ApiResponse<ContactOutput>>)> {
494 app.meta_adapter.get_address_book(tn_id, ab_id).await?.ok_or(Error::NotFound)?;
495 body.uid = Some(uid);
496 let out = write_contact(&app, tn_id, ab_id, body).await?;
497 let mut resp = ApiResponse::new(out);
498 if let Some(id) = req_id {
499 resp = resp.with_req_id(id);
500 }
501 Ok((StatusCode::OK, Json(resp)))
502}
503
504pub async fn patch_contact(
505 State(app): State<App>,
506 tn_id: TnId,
507 IdTag(_id_tag): IdTag,
508 Auth(_auth): Auth,
509 OptionalRequestId(req_id): OptionalRequestId,
510 Path((ab_id, uid)): Path<(u64, String)>,
511 Json(patch): Json<ContactPatch>,
512) -> ClResult<(StatusCode, Json<ApiResponse<ContactOutput>>)> {
513 let stored = app.meta_adapter.get_contact(tn_id, ab_id, &uid).await?.ok_or(Error::NotFound)?;
514
515 let mut merged = stored_to_input(&stored)?;
516 apply_contact_patch(&mut merged, patch);
517 merged.uid = Some(stored.uid.to_string());
518
519 let out = write_contact(&app, tn_id, ab_id, merged).await?;
520 let mut resp = ApiResponse::new(out);
521 if let Some(id) = req_id {
522 resp = resp.with_req_id(id);
523 }
524 Ok((StatusCode::OK, Json(resp)))
525}
526
527pub async fn delete_contact(
528 State(app): State<App>,
529 tn_id: TnId,
530 IdTag(_id_tag): IdTag,
531 Auth(_auth): Auth,
532 Path((ab_id, uid)): Path<(u64, String)>,
533 OptionalRequestId(req_id): OptionalRequestId,
534) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
535 app.meta_adapter.delete_contact(tn_id, ab_id, &uid).await?;
536 let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
537 Ok((StatusCode::OK, Json(response)))
538}
539
540#[allow(clippy::too_many_arguments)]
552pub async fn import_contacts(
553 State(app): State<App>,
554 tn_id: TnId,
555 IdTag(_id_tag): IdTag,
556 Auth(_auth): Auth,
557 OptionalRequestId(req_id): OptionalRequestId,
558 Path(ab_id): Path<u64>,
559 Query(query): Query<ImportContactsQuery>,
560 body: String,
561) -> ClResult<(StatusCode, Json<ApiResponse<ImportContactsResult>>)> {
562 app.meta_adapter.get_address_book(tn_id, ab_id).await?.ok_or(Error::NotFound)?;
563
564 let mode = query.conflict.unwrap_or_default();
565 let cards = vcard::split_cards(&body);
566 let mut result = ImportContactsResult {
567 total: u32::try_from(cards.len()).unwrap_or(u32::MAX),
568 ..Default::default()
569 };
570
571 let parsed: Vec<Option<crate::types::ContactInput>> =
574 cards.iter().map(|card| vcard::parse(card).map(|(input, _, _)| input)).collect();
575 let candidate_uids: Vec<String> = parsed
576 .iter()
577 .filter_map(|p| p.as_ref()?.uid.clone())
578 .filter(|u| !u.is_empty())
579 .collect();
580 let existing: std::collections::HashSet<String> = if candidate_uids.is_empty() {
581 std::collections::HashSet::new()
582 } else {
583 let refs: Vec<&str> = candidate_uids.iter().map(String::as_str).collect();
584 app.meta_adapter
585 .get_contacts_by_uids(tn_id, ab_id, &refs)
586 .await?
587 .into_iter()
588 .map(|c| c.uid.to_string())
589 .collect()
590 };
591
592 for (i, parsed_slot) in parsed.into_iter().enumerate() {
593 let idx = u32::try_from(i).unwrap_or(u32::MAX);
594 let Some(mut input) = parsed_slot else {
595 result.errors.push(ImportContactsError {
596 index: idx,
597 uid: None,
598 message: "could not parse vCard block".into(),
599 });
600 continue;
601 };
602
603 let incoming_uid = input.uid.clone();
604 let exists = incoming_uid
605 .as_deref()
606 .is_some_and(|uid| !uid.is_empty() && existing.contains(uid));
607
608 match (exists, mode) {
609 (true, ImportConflictMode::Skip) => {
610 result.skipped += 1;
611 continue;
612 }
613 (_, ImportConflictMode::Add) => {
614 input.uid = None;
616 }
617 _ => {}
619 }
620
621 match write_contact(&app, tn_id, ab_id, input).await {
622 Ok(_) => {
623 if exists && mode == ImportConflictMode::Replace {
624 result.updated += 1;
625 } else {
626 result.imported += 1;
627 }
628 }
629 Err(e) => result.errors.push(ImportContactsError {
630 index: idx,
631 uid: incoming_uid,
632 message: e.to_string(),
633 }),
634 }
635 }
636
637 let mut resp = ApiResponse::new(result);
638 if let Some(id) = req_id {
639 resp = resp.with_req_id(id);
640 }
641 Ok((StatusCode::OK, Json(resp)))
642}
643
644