Upsert keyed entities on POST /api/<entity> (#1)
Test backoffice (smb-crm) / test (push) Has been cancelled
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:
+35
-3
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user