feat(org): contacto-empresa en la agenda con los telefonos de sus personas etiquetados

sync_org_contact_cards crea un contacto de agenda por organizacion (uid
org-<slug>); el push compone su vCard via _org_contact_vcard con un item.TEL +
item.X-ABLabel por persona de contacto (nombre + rol) desde derived.org_contacts.
Asi, al abrir la empresa en el movil, se ven todos los telefonos identificados por
persona. Sin campos OSINT (misma privacidad que el resto de la agenda). Nuevo
endpoint POST /api/org/sync-contact-cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:24:46 +02:00
parent d53d7a9a7e
commit 9677903ca6
2 changed files with 114 additions and 0 deletions
+6
View File
@@ -399,6 +399,12 @@ def create_app(cfg: Config) -> FastAPI:
# personas de contacto con teléfono y rol (desde derived.org_contacts).
return _guard(lambda: writes.render_all_org_contacts(cfg))
@app.post("/api/org/sync-contact-cards")
def org_sync_contact_cards() -> dict:
# Crea/actualiza un contacto de agenda por organización (uid org-<slug>);
# el push compone su vCard con los teléfonos de sus personas etiquetados.
return _guard(lambda: writes.sync_org_contact_cards(cfg))
return app
+108
View File
@@ -31,6 +31,7 @@ from .registry_bridge import (
build_vcard,
caldav_put_event,
carddav_put_vcard,
contact_import_key,
create_obsidian_note,
dav_delete_resource,
dav_get_resource,
@@ -356,6 +357,12 @@ def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str:
Returns:
Texto vCard 3.0 listo para PUT a Xandikos.
"""
# Contacto-empresa (uid 'org-<slug>'): el vCard lleva los teléfonos de las
# personas de contacto de la organización, cada uno etiquetado con el nombre
# de la persona (item.X-ABLabel). Lo gestiona _org_contact_vcard.
if str(uid).startswith("org-"):
return _org_contact_vcard(cfg, uid, fields.get("fn") or fields.get("nombre"))
tels = _as_list(fields.get("tels") or fields.get("telefonos"))
emails = _as_list(fields.get("emails") or fields.get("correos"))
fn = fields.get("fn") or fields.get("nombre")
@@ -381,6 +388,107 @@ def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str:
return build_vcard(contact)
def _vc_escape(value) -> str:
"""Escapa un valor de texto para una línea vCard (sin \\r; \\n,;, escapados)."""
return (
str(value)
.replace("\\", "\\\\")
.replace("\r", "")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
def _org_contact_vcard(cfg: Config, uid: str, fn) -> str:
"""vCard de una EMPRESA con los teléfonos de sus personas de contacto.
Cada teléfono va en una propiedad agrupada ``item<N>.TEL`` etiquetada con
``item<N>.X-ABLabel`` = "<persona> (<rol>)", leídos de
``derived.org_contacts``. Así, al abrir la empresa en el móvil, se ven todos
los teléfonos identificados por la persona de contacto. Sin ningún campo OSINT
(mismo criterio de privacidad que _compose_agenda_vcard).
"""
slug = uid[len("org-"):] if str(uid).startswith("org-") else str(uid)
name = str(fn).strip() if fn else slug
res = duckdb_query_readonly(
cfg.db_path,
"SELECT persona, rol, telefono FROM derived.org_contacts "
"WHERE org_slug = ? ORDER BY persona, telefono",
[slug],
1000,
)
rows = res.get("rows") or []
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"UID:%s" % _vc_escape(uid),
"FN:%s" % _vc_escape(name),
"ORG:%s" % _vc_escape(name),
]
seen = set()
i = 0
for r in rows:
tel = str(r.get("telefono") or "").strip()
if not tel or tel in seen:
continue
seen.add(tel)
i += 1
persona = (r.get("persona") or "contacto").strip()
rol = (r.get("rol") or "").strip()
label = "%s (%s)" % (persona, rol) if rol and rol != "contacto" else persona
lines.append("item%d.TEL;TYPE=CELL:%s" % (i, _vc_escape(tel)))
lines.append("item%d.X-ABLabel:%s" % (i, _vc_escape(label)))
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
def sync_org_contact_cards(cfg: Config) -> dict:
"""Crea/actualiza un contacto de agenda por organización con contactos.
Por cada organización con filas en ``derived.org_contacts`` inserta (o
refresca) una fila en ``contacts`` con uid ``org-<slug>`` y el nombre de la
empresa. El vCard real (con los teléfonos etiquetados por persona) lo compone
el push via ``_org_contact_vcard``. Idempotente y no destructivo. Devuelve el
número de tarjetas-empresa sincronizadas.
"""
orgs = duckdb_query_readonly(
cfg.db_path,
"SELECT DISTINCT d.org_slug, d.org_nombre FROM derived.org_contacts d "
"ORDER BY d.org_slug",
[],
100000,
)
rows = orgs.get("rows") or []
upserted = 0
for o in rows:
slug = o.get("org_slug")
nombre = o.get("org_nombre") or slug
uid = "org-%s" % slug
row = {
"uid": uid,
"collection": cfg.dav_contacts_collection,
"etag": None,
"fn": nombre,
"tels": "[]",
"emails": "[]",
"raw": "",
"note_path": None,
"updated_at": _now(),
"import_key": contact_import_key(nombre, [], []),
}
res = duckdb_upsert(
cfg.db_path,
"contacts",
[row],
key_cols=["uid"],
update_cols=["collection", "fn", "updated_at", "import_key"],
)
if res.get("status") == "ok":
upserted += 1
return {"status": "ok", "org_cards": upserted}
def _dedup_keep_order(items: list) -> list:
"""Deduplica una lista de strings preservando orden (case-insensitive)."""
seen, out = set(), []