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]>
132 lines
5.2 KiB
Bash
Executable File
132 lines
5.2 KiB
Bash
Executable File
#!/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}"
|