File size: 6,803 Bytes
ea7b176
c14ceee
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c14ceee
 
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
c14ceee
 
 
ea7b176
c14ceee
 
 
 
 
ea7b176
c14ceee
ea7b176
c14ceee
 
 
 
 
 
 
ea7b176
 
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
c14ceee
 
 
 
 
 
 
ea7b176
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
c14ceee
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
"""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"])