#!/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=) and dedupe # 1. by case-insensitive email, then # 2. by case-insensitive (lastname, firstname). # On a match: emit {"id": , "deduped": true} and exit 0 without # POSTing. Otherwise POST /contacts and emit {"id": , "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}"