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:
@@ -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}"
|
||||
Reference in New Issue
Block a user