feat(write-skill): client-dossier ops — thirdparty update (allowlisted) + idempotent contacts

Part of erp#65 (phase 1). Ledger grammar "thirdparty complete" gets its
op: allowlisted non-ledger fields, per-field diff read-back. Contacts
are born idempotent (dedupe by email then name). Promote ops wired both
targets, offline stub tests, SKILL.md workflows, KM dossier manifest
(unsigned-contract truth fix + EIN-to-collect note).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-15 19:49:13 +02:00
co-authored by Claude Fable 5
parent 6b546e77da
commit 35b227eb6d
9 changed files with 562 additions and 23 deletions
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Create a contact (socpeople) on a thirdparty in the SANDBOX — IDEMPOTENT from
# day one (the erp#44 pattern, applied at birth).
#
# Input: a JSON object on stdin (or a file path / inline JSON in $1):
# socid (required) the thirdparty the contact belongs to
# lastname (required)
# firstname, poste (job title), email, phone (stored as phone_pro),
# phone_pro, phone_perso, phone_mobile, address, zip, town, country_id,
# note_public, note_private (all optional)
# Unknown fields are REFUSED, never silently dropped — notably the WIP operator
# payloads' `soc2` is NOT a Dolibarr field: the job title is `poste`.
#
# Idempotency: BEFORE any POST, list the target's existing contacts
# (GET /contacts?thirdparty_ids=<socid>) and dedupe
# 1. by case-insensitive email, then
# 2. by case-insensitive (lastname, firstname).
# On a match: emit {"id": <existing>, "deduped": true} and exit 0 without
# POSTing. Otherwise POST /contacts and emit {"id": <new>, "deduped": false}.
#
# The Dolibarr API answers HTTP 404 (not []) when a thirdparty has no contacts —
# that failure, and only that one, is treated as "no contacts yet". Any other
# listing failure ABORTS: assuming "empty" on e.g. a 403 would mint duplicates.
#
# All requests go through dol-write.sh (or $DOL_WRITE), which refuses any host
# that is not the sandbox.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
SRC="${1:-}"
if [[ -z "${SRC}" || "${SRC}" == "-" ]]; then INPUT="$(cat)"
elif [[ -f "${SRC}" ]]; then INPUT="$(cat "${SRC}")"
else INPUT="${SRC}"; fi # inline JSON
TMPD="$(mktemp -d -t ctcre.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT
# --- 1. Validate + map the POST body (before ANY request) ----------------------
cat > "${TMPD}/validate.py" <<'PY'
import json, sys
ALLOWED = ["socid", "lastname", "firstname", "poste", "email", "phone",
"phone_pro", "phone_perso", "phone_mobile", "address", "zip", "town",
"country_id", "note_public", "note_private"]
try:
d = json.loads(sys.stdin.read() or "null")
except Exception as e:
sys.exit("contact-create.sh: input is not valid JSON: %s" % e)
if not isinstance(d, dict):
sys.exit("contact-create.sh: input must be a JSON object")
if "soc2" in d:
sys.exit("contact-create.sh: REFUSED — 'soc2' is not a Dolibarr contact field "
"(the API would silently drop it); the job title field is 'poste'")
bad = sorted(k for k in d if k not in ALLOWED)
if bad:
sys.exit("contact-create.sh: REFUSED — unknown field(s): %s\n allowed: %s"
% (", ".join(bad), ", ".join(ALLOWED)))
socid = str(d.get("socid", ""))
if not socid.isdigit():
sys.exit("contact-create.sh: 'socid' is required (numeric), got %r" % socid)
if not str(d.get("lastname", "")).strip():
sys.exit("contact-create.sh: 'lastname' is required")
body = {"socid": socid, "lastname": str(d["lastname"]).strip()}
for k in ALLOWED:
if k in ("socid", "lastname", "phone"):
continue
v = d.get(k)
if v not in (None, ""):
body[k] = v
# `phone` is the office line — Dolibarr contacts store it as phone_pro.
if d.get("phone") not in (None, "") and "phone_pro" not in body:
body["phone_pro"] = d["phone"]
print(socid)
print(json.dumps(body, ensure_ascii=False))
PY
MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/validate.py")"
SOCID="$(sed -n 1p <<<"${MAPPED}")"
BODY="$(sed -n 2p <<<"${MAPPED}")"
printf '%s' "${BODY}" > "${TMPD}/body.json"
# --- 2. Dedupe against the target's existing contacts --------------------------
set +e
"${W}" GET "/contacts?thirdparty_ids=${SOCID}&limit=500" \
> "${TMPD}/list.json" 2> "${TMPD}/list.err"
rc=$?
set -e
if [[ ${rc} -ne 0 ]]; then
if grep -q "HTTP 404" "${TMPD}/list.err"; then
printf '[]' > "${TMPD}/list.json" # empty contact list answers 404, not []
else
cat "${TMPD}/list.err" >&2
echo "contact-create.sh: could not list contacts for socid ${SOCID} — refusing to POST blind (dedupe impossible)" >&2
exit 1
fi
fi
cat > "${TMPD}/match.py" <<'PY'
import json, sys
body = json.load(open(sys.argv[1]))
try:
rows = json.load(open(sys.argv[2]))
except Exception:
rows = []
rows = rows if isinstance(rows, list) else []
def norm(v): return str(v or "").strip().lower()
email, ln, fn = norm(body.get("email")), norm(body.get("lastname")), norm(body.get("firstname"))
hit = None
if email:
for r in rows:
if norm(r.get("email")) == email:
hit = r; break
if hit is None:
for r in rows:
if norm(r.get("lastname")) == ln and norm(r.get("firstname")) == fn:
hit = r; break
if hit is not None:
print(json.dumps({"id": int(hit["id"]), "deduped": True}))
PY
MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/body.json" "${TMPD}/list.json")"
if [[ -n "${MATCH}" ]]; then
echo "contact-create.sh: contact already exists on socid ${SOCID} — deduped, no POST" >&2
printf '%s\n' "${MATCH}"
exit 0
fi
# --- 3. POST the new contact ----------------------------------------------------
NEWID="$("${W}" POST /contacts "${BODY}")"
if [[ ! "${NEWID}" =~ ^[0-9]+$ ]]; then
echo "contact-create.sh: contact POST did not return an id: ${NEWID}" >&2
exit 1
fi
printf '{"id": %s, "deduped": false}\n' "${NEWID}"