Upsert keyed entities on POST /api/<entity> (#1)
Test backoffice (smb-crm) / test (push) Has been cancelled

Easy!Appointments re-fires appointment_save on reschedule with the same
EA-<id> booking_id, so add_entity's plain INSERT 500s on the PK conflict.
Switch to ON CONFLICT (pk) DO UPDATE for every entity except activity_log
(no client-supplied pk), logging add vs update accordingly. Extended to
all keyed entities per the issue body, while preserving created_at/
received_at on redelivery for leads/clients/credentials so a redelivered
webhook can't clobber the original creation time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 13:42:00 +02:00
parent aabd1d56c4
commit 1b8f8c1391
2 changed files with 195 additions and 3 deletions
+35 -3
View File
@@ -129,6 +129,17 @@ def next_client_id(cur):
return "C-%04d" % (mx + 1)
# Columns that add_entity defaults to "now" only when the caller omits them
# (see the per-entity blocks below) -- a re-delivered webhook that omits the
# same column on a redelivery must not have it upserted back to a fresh
# "now", clobbering the original creation time of the row it's updating.
INSERT_ONLY_COLS = {
"leads": {"received_at"},
"clients": {"created_at"},
"credentials": {"created_at"},
}
@app.post("/api/<entity>")
def add_entity(entity):
if entity not in db.TABLES:
@@ -160,10 +171,31 @@ def add_entity(entity):
return jsonify({"error": f"{pk} required"}), 400
cols = spec["cols"]
ph = ", ".join(["%s"] * len(cols))
cur.execute(f"INSERT INTO {entity} ({', '.join(cols)}) VALUES ({ph})",
[row.get(c) for c in cols])
log_activity(cur, row.get("client_id"), f"add {entity}", f"{pk}={row.get(pk)}")
collist = ", ".join(cols)
if entity == "activity_log":
# activity_log has no client-supplied primary key (its real pk,
# a serial "id", isn't even in cols) -- always a fresh row.
cur.execute(f"INSERT INTO {entity} ({collist}) VALUES ({ph})",
[row.get(c) for c in cols])
updated = False
else:
# Upsert by pk (#1): re-delivered webhooks -- e.g. Easy!Appointments
# re-firing appointment_save with the same EA-<id> booking_id on
# reschedule -- must update the existing row instead of 500ing on
# a PK conflict.
skip = INSERT_ONLY_COLS.get(entity, set()) | {pk}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c not in skip)
cur.execute(
f"INSERT INTO {entity} ({collist}) VALUES ({ph}) "
f"ON CONFLICT ({pk}) DO UPDATE SET {updates} "
f"RETURNING (xmax = 0) AS inserted",
[row.get(c) for c in cols])
updated = not cur.fetchone()["inserted"]
log_activity(cur, row.get("client_id"),
f"{'update' if updated else 'add'} {entity}", f"{pk}={row.get(pk)}")
conn.commit()
if updated:
return jsonify({"updated": row.get(pk)}), 200
return jsonify({"added": row.get(pk)}), 201