Skip to main content

cloudillo_contact/
handler.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! JSON REST handlers for address books and contacts.
5//!
6//! Structured-only shape: clients send/receive typed JSON; the server is the sole authority
7//! on vCard generation and field extraction. Custom vCard properties from external CardDAV
8//! clients round-trip through the stored blob but don't surface here.
9
10use 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
37// Shared helpers
38//****************
39
40fn 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
51/// Resolve profiles for a batch of contacts in one query, returning `None` overlays for
52/// any that aren't locally known. Uses `read_profile` per distinct id_tag — for v1 this
53/// is fine (address books are typically small); a later batched `read_profiles` would be
54/// a simple optimization.
55async 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	// Re-parse the stored vCard to recover emails[]/phones[] structure for the JSON response.
78	// Projected columns alone lose the per-entry TYPE/PREF parameters, so we need the blob.
79	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	// Photo URL may need to be derived from profile overlay when the stored blob lacks one.
95	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
167/// Apply a `ContactPatch` to a full `ContactInput` (already hydrated from storage).
168fn 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
216/// Hydrate a stored contact's full `ContactInput` from its vCard blob (for PATCH merge).
217/// Returns `Error::Internal` when the stored blob does not parse — merging patch input
218/// against an empty base would silently clear every field on the row.
219fn 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
233/// Shared write path: merges profile data, generates vCard, upserts, returns the fresh row.
234async fn write_contact(
235	app: &App,
236	tn_id: TnId,
237	ab_id: u64,
238	mut input: ContactInput,
239) -> ClResult<ContactOutput> {
240	// Ensure UID is set.
241	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	// Resolve linked profile (if any) and apply smart merge for empty fields.
251	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	// Generate vCard, compute etag, build extracted projection.
258	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	// Build overlay map (at most one entry) for the response.
270	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
278// Timestamp formatting helper — vCard REV wants compact basic ISO 8601 (yyyymmddThhmmssZ).
279fn 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
286// Handlers
287//**********
288
289// Address books
290//***************
291
292pub 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
308/// Names flow into the CardDAV collection URI and DAV response XML, so a newline or slash
309/// can corrupt headers or split the URL. Cap at 128 bytes to keep URLs reasonable.
310fn 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
384// Contacts
385//**********
386
387async 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	// Adapter over-fetches by 1 so we can distinguish "exact-fit page" from "more rows".
398	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	// Address book must exist.
476	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/// POST /api/address-books/{ab_id}/import?conflict=skip|replace|add
541///
542/// Body: `text/vcard` containing one or more `BEGIN:VCARD ... END:VCARD` blocks.
543/// Returns a per-card result summary with any parse/write failures.
544///
545/// Conflict modes (matched by vCard UID against existing rows in the address book):
546/// - `skip` (default) — keep the existing contact, count it under `skipped`.
547/// - `replace` — overwrite the existing contact (same UID), count under `updated`.
548/// - `add` — drop the incoming UID and mint a fresh one so the card lands as a new
549///   contact alongside the existing one. Useful when the user wants every card from
550///   the file to land regardless of UID collisions.
551#[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	// Parse every card once up-front so the existence check can see all candidate UIDs
572	// and we only hit the DB with a single batch lookup instead of a per-card roundtrip.
573	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				// Force a new UID so the card lands as a fresh contact even on collision.
615				input.uid = None;
616			}
617			// Replace, or non-collision Skip/Replace, fall through to write_contact (upsert).
618			_ => {}
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// vim: ts=4