"""Durable comments attached to records, ONE BUCKET PER DATABASE. The service is deliberately unaware of HTTP and of the concrete store backend. It receives a tenant-bound ``TenantRuntime`` handle, so the same logical key can never cross a tenant boundary. Odoo remains read-only: comments live only in AIOS's writable tenant store. ⭐ WAVE 19 (owner item 12) — THE BUCKET IS PER TOPIC. Every entry here is keyed by a bare pid, and a pid only identifies a row INSIDE one database: customer pids are Odoo partner ids, product pids are CRC32 hashes of SKU codes, a user table's are its own row numbers. One shared bucket therefore had two failure modes, and the quiet one is why this changed: * loud — a product record asked the customer endpoint about its hash, which is not in the caller's book, and the panel said "that customer is not in your book" on a screen showing a product. This is what the owner reported. * quiet — a hash that lands on a real partner id answers 200, and the comment is filed against somebody's customer, readable by everyone who can open that customer. ``key_for(scope)`` gives each database its own bucket. THE CUSTOMER TOPIC KEEPS THE LEGACY KEY, so nothing that has been written moves; ``STORE_KEY`` stays exported under its old name and value for the same reason. """ from __future__ import annotations import datetime as dt import uuid STORE_KEY = "customer_record_comments" MAX_BODY = 4000 MAX_PER_RECORD = 500 #: The scope names that mean THE CUSTOMER BOOK — mirrors ``modules.cohort.LEGACY_SCOPES``. #: Restated rather than imported: ``core/`` does not import ``modules/`` (ARCHITECTURE §1's #: one-directional rule), and this file has stayed on the right side of that line. LEGACY_SCOPES = frozenset({"", "customer", "cohort"}) def key_for(scope=None): """The comments bucket ONE topic's records live in. Customer keeps the shipped key.""" s = str(scope or "customer").strip().lower() return STORE_KEY if s in LEGACY_SCOPES else f"{s}_record_comments" class CommentsUnavailable(RuntimeError): """The tenant store cannot currently provide durable comments.""" class CommentForbidden(RuntimeError): """The caller tried to remove another user's comment.""" def _now_iso(): return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") def _record_key(pid): return str(int(pid)) def _clean_comment(raw): if not isinstance(raw, dict): return None cid = str(raw.get("id") or "").strip() body = str(raw.get("body") or "").strip() author_key = str(raw.get("authorKey") or "").strip().lower() author = str(raw.get("author") or author_key).strip() created_at = str(raw.get("createdAt") or "").strip() if not cid or not body or not author_key or not created_at: return None return { "id": cid, "body": body[:MAX_BODY], "authorKey": author_key, "author": author[:120] or author_key, "createdAt": created_at, } def _read(runtime, scope=None): if not runtime.available(): raise CommentsUnavailable("the tenant store is unavailable") try: data = runtime.get(key_for(scope)) except Exception as exc: raise CommentsUnavailable("comments could not be read") from exc return data if isinstance(data, dict) else {} def list_comments(runtime, pid, scope=None): """Return one record's comments oldest-first, dropping malformed legacy entries.""" rows = _read(runtime, scope).get(_record_key(pid), []) if not isinstance(rows, list): return [] cleaned = [comment for raw in rows if (comment := _clean_comment(raw)) is not None] cleaned.sort(key=lambda comment: (comment["createdAt"], comment["id"])) return cleaned def add_comment(runtime, pid, body, author_key, author_name="", now=None, comment_id=None, scope=None): """Append one server-authored comment atomically and synchronously.""" text = str(body or "").strip() if not text: raise ValueError("write a comment before posting") if len(text) > MAX_BODY: raise ValueError(f"comments may be at most {MAX_BODY} characters") uname = str(author_key or "").strip().lower() if not uname: raise ValueError("a signed-in author is required") comment = { "id": str(comment_id or uuid.uuid4().hex), "body": text, "authorKey": uname, "author": str(author_name or uname).strip()[:120] or uname, "createdAt": str(now or _now_iso()), } if not runtime.available(): raise CommentsUnavailable("the tenant store is unavailable") def _append(data): if not isinstance(data, dict): data = {} key = _record_key(pid) rows = data.get(key) if not isinstance(rows, list): rows = [] if len(rows) >= MAX_PER_RECORD: raise ValueError(f"a record may hold at most {MAX_PER_RECORD} comments") rows.append(comment) data[key] = rows return data try: runtime.update(key_for(scope), _append, flush="sync") except ValueError: raise except Exception as exc: raise CommentsUnavailable("the comment was not saved") from exc return dict(comment) def delete_comment(runtime, pid, comment_id, actor_key, admin=False, scope=None): """Delete a comment owned by ``actor_key`` (or by an administrator).""" cid = str(comment_id or "").strip() actor = str(actor_key or "").strip().lower() if not cid: return False if not runtime.available(): raise CommentsUnavailable("the tenant store is unavailable") state = {"found": False, "forbidden": False} def _delete(data): if not isinstance(data, dict): return {} key = _record_key(pid) rows = data.get(key) if not isinstance(rows, list): return data kept = [] for raw in rows: comment = _clean_comment(raw) if comment is None or comment["id"] != cid: kept.append(raw) continue state["found"] = True if not admin and comment["authorKey"] != actor: state["forbidden"] = True kept.append(raw) if kept: data[key] = kept else: data.pop(key, None) return data try: runtime.update(key_for(scope), _delete, flush="sync") except Exception as exc: raise CommentsUnavailable("the comment was not deleted") from exc if state["forbidden"]: raise CommentForbidden("only the author or an administrator may delete this comment") return bool(state["found"])