hoja-cli 0.21.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
hoja_server/store.py ADDED
@@ -0,0 +1,2179 @@
1
+ """Store — org-scoped data access + serialization to Linear camelCase shapes.
2
+
3
+ Every method takes an org_id and NEVER returns rows from other orgs → tenant
4
+ isolation by construction. Serializers emit exactly the camelCase field names
5
+ hoja's Pydantic models expect.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import hmac
12
+ import secrets
13
+ from datetime import datetime, timedelta, timezone
14
+
15
+ from graphql import GraphQLError
16
+ from sqlalchemy import and_, func, or_, select
17
+ from sqlalchemy.engine import Engine
18
+
19
+ from hoja_server.db import (
20
+ api_key,
21
+ attachment,
22
+ change_event,
23
+ browser_session,
24
+ comment,
25
+ cycle,
26
+ issue,
27
+ issue_activity,
28
+ inbox_notification,
29
+ initiative,
30
+ initiative_project,
31
+ organization,
32
+ global_identity,
33
+ organization_membership,
34
+ identity_credential,
35
+ membership_invitation,
36
+ new_id,
37
+ issue_label,
38
+ issue_label_link,
39
+ issue_subscriber,
40
+ project,
41
+ project_update,
42
+ project_member,
43
+ project_team,
44
+ team,
45
+ team_member,
46
+ token_hash,
47
+ password_credential,
48
+ user,
49
+ user_preference,
50
+ workflow_state,
51
+ )
52
+
53
+
54
+ def change_event_dict(r, *, with_org: bool = False) -> dict:
55
+ """change_event row → ChangeEvent; recipientId only for per-user events (notifications)."""
56
+ out = {"seq": r["seq"], "entity": r["entity"], "entityId": r["entity_id"],
57
+ "action": r["action"], "teamId": r["team_id"]}
58
+ if r.get("recipient_id") is not None:
59
+ out["recipientId"] = r["recipient_id"]
60
+ if with_org:
61
+ out["organization_id"] = r["organization_id"]
62
+ return out
63
+
64
+
65
+ def _conn_rows(conn, stmt) -> list[dict]:
66
+ return [dict(r._mapping) for r in conn.execute(stmt)]
67
+
68
+
69
+ def _one(conn, stmt) -> dict | None:
70
+ r = conn.execute(stmt).first()
71
+ return dict(r._mapping) if r else None
72
+
73
+
74
+ def _normalize_expiry(value: str | None):
75
+ """Return a UTC ISO timestamp, None for no expiry, or False when invalid or already past."""
76
+ if value is None:
77
+ return None
78
+ if not isinstance(value, str):
79
+ raise GraphQLError("expiresAt must be an ISO 8601 datetime string.",
80
+ extensions={"code": "BAD_USER_INPUT", "field": "expiresAt"})
81
+ try:
82
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
83
+ except ValueError:
84
+ return False
85
+ if parsed.tzinfo is None:
86
+ parsed = parsed.replace(tzinfo=timezone.utc)
87
+ parsed = parsed.astimezone(timezone.utc)
88
+ return parsed.isoformat() if parsed > datetime.now(timezone.utc) else False
89
+
90
+
91
+ _MAX_SEARCH_TERM = 256
92
+ _PBKDF2_ITERATIONS = 600_000
93
+ _LEGACY_PBKDF2_ITERATIONS = 310_000
94
+
95
+
96
+ def _check_search_term(value) -> str:
97
+ if not isinstance(value, str):
98
+ raise GraphQLError("Search term must be a string.", extensions={"code": "BAD_USER_INPUT"})
99
+ if len(value) > _MAX_SEARCH_TERM:
100
+ raise GraphQLError(f"Search term must be at most {_MAX_SEARCH_TERM} characters.",
101
+ extensions={"code": "BAD_USER_INPUT"})
102
+ return value
103
+
104
+
105
+ def _like_contains(value: str) -> str:
106
+ """Escape LIKE wildcards so user text matches literally (pair with escape="\\")."""
107
+ escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
108
+ return f"%{escaped}%"
109
+
110
+
111
+ PUBLIC, RESTRICTED, PRIVATE = "public", "restricted", "private"
112
+ TEAM_ACCESS_LEVELS = (PUBLIC, RESTRICTED, PRIVATE)
113
+ TEAM_ROLES = ("owner", "member")
114
+
115
+
116
+ def _member_team_ids(viewer_id: str):
117
+ """Subquery of the team ids this viewer holds a team_member row on."""
118
+ return select(team_member.c.team_id).where(team_member.c.user_id == viewer_id)
119
+
120
+
121
+ def visibility_holds(team_cols, viewer_id: str):
122
+ """SQL predicate over one team row: may ``viewer_id`` see it?
123
+
124
+ The single definition of the visibility rule (decision 2 of the brief):
125
+ public — any member of the organization;
126
+ restricted — explicit members, plus members of the parent team (they may also join);
127
+ private — explicit members only.
128
+
129
+ Departments are not an access boundary, so a restricted team looks at its direct
130
+ parent only; the membership cascade keeps an explicit row on every ancestor, which
131
+ is what makes a grandparent member count here too. Everything else (reads, boards,
132
+ search, attachments, realtime) is expressed through this predicate.
133
+ """
134
+ members = _member_team_ids(viewer_id)
135
+ return or_(
136
+ team_cols["visibility"] == PUBLIC,
137
+ team_cols["id"].in_(members),
138
+ and_(team_cols["visibility"] == RESTRICTED, team_cols["parent_team_id"].in_(members)),
139
+ )
140
+
141
+
142
+ def visible_team_select(org_id: str, viewer_id: str, *, alias: str = "visible_team"):
143
+ """Subquery of the team ids in ``org_id`` that ``viewer_id`` may see."""
144
+ vt = team.alias(alias)
145
+ cols = {"id": vt.c.id, "visibility": vt.c.visibility, "parent_team_id": vt.c.parent_team_id}
146
+ return select(vt.c.id).where(and_(vt.c.organization_id == org_id, visibility_holds(cols, viewer_id)))
147
+
148
+
149
+ def _viewer_is_admin(conn, org_id: str, viewer_id: str) -> bool:
150
+ return bool(conn.execute(select(user.c.admin).where(and_(
151
+ user.c.id == viewer_id, user.c.organization_id == org_id))).scalar())
152
+
153
+
154
+ def visible_team_clause(conn, org_id: str, viewer_id: str, team_id_col):
155
+ """Restrict team_id_col to teams the viewer may see. None means unrestricted (admin)."""
156
+ if _viewer_is_admin(conn, org_id, viewer_id):
157
+ return None
158
+ return team_id_col.in_(visible_team_select(org_id, viewer_id))
159
+
160
+
161
+ def team_ancestor_ids(conn, org_id: str, team_id: str) -> list[str]:
162
+ """The team followed by its parents, root last (nesting is capped at three levels)."""
163
+ chain, current = [], team_id
164
+ while current and current not in chain:
165
+ parent = conn.execute(select(team.c.parent_team_id).where(and_(
166
+ team.c.id == current, team.c.organization_id == org_id))).scalar()
167
+ chain.append(current)
168
+ current = parent
169
+ return chain
170
+
171
+
172
+ def team_descendant_ids(conn, org_id: str, team_id: str) -> list[str]:
173
+ """The team followed by every team beneath it, breadth first."""
174
+ seen, queue = [team_id], [team_id]
175
+ while queue:
176
+ children = list(conn.execute(select(team.c.id).where(and_(
177
+ team.c.organization_id == org_id, team.c.parent_team_id == queue.pop(0)))).scalars())
178
+ for child in children:
179
+ if child not in seen:
180
+ seen.append(child)
181
+ queue.append(child)
182
+ return seen
183
+
184
+
185
+ def team_visible(conn, org_id: str, viewer_id: str, team_id: str) -> bool:
186
+ stmt = select(team.c.id).where(and_(team.c.id == team_id, team.c.organization_id == org_id))
187
+ clause = visible_team_clause(conn, org_id, viewer_id, team.c.id)
188
+ if clause is not None:
189
+ stmt = stmt.where(clause)
190
+ return conn.execute(stmt).first() is not None
191
+
192
+
193
+ def _scope_teams(conn, stmt, org_id, viewer_id, team_id_col, *, nullable=False):
194
+ """Apply team visibility when a viewer is given. viewer_id=None is an internal, unscoped call."""
195
+ if viewer_id is None:
196
+ return stmt
197
+ clause = visible_team_clause(conn, org_id, viewer_id, team_id_col)
198
+ if clause is None:
199
+ return stmt
200
+ return stmt.where(or_(team_id_col.is_(None), clause) if nullable else clause)
201
+
202
+ def _scope_projects(conn, stmt, org_id, viewer_id):
203
+ """Hide projects whose every team link is invisible to the viewer.
204
+
205
+ A project with no team links keeps the historical behaviour and stays org-wide:
206
+ it has no team to be private about. A project linked only to teams the viewer
207
+ cannot see disappears, mirroring Linear's rule that a project becomes private
208
+ once its last public team is removed.
209
+ """
210
+ if viewer_id is None:
211
+ return stmt
212
+ clause = visible_team_clause(conn, org_id, viewer_id, team.c.id)
213
+ if clause is None:
214
+ return stmt
215
+ visible_ids = (
216
+ select(project_team.c.project_id)
217
+ .select_from(project_team.join(team, team.c.id == project_team.c.team_id))
218
+ .where(clause)
219
+ )
220
+ any_linked = select(project_team.c.project_id)
221
+ return stmt.where(or_(~project.c.id.in_(any_linked), project.c.id.in_(visible_ids)))
222
+
223
+
224
+ class Store:
225
+ def __init__(self, engine: Engine) -> None:
226
+ self.engine = engine
227
+
228
+ def is_initialized(self) -> bool:
229
+ """Whether this database already contains any workspace."""
230
+ with self.engine.connect() as conn:
231
+ return conn.execute(select(organization.c.id).limit(1)).first() is not None
232
+
233
+ def user_preferences(self, user_id: str) -> dict[str, str] | None:
234
+ """Read preference values for a verified user profile."""
235
+ with self.engine.connect() as conn:
236
+ if not conn.execute(select(user.c.id).where(
237
+ user.c.id == user_id, user.c.active.is_(True),
238
+ user.c.archived_at.is_(None))).first():
239
+ return None
240
+ rows = conn.execute(select(user_preference.c.preference_key,
241
+ user_preference.c.value).where(
242
+ user_preference.c.user_id == user_id)).all()
243
+ return {row.preference_key: row.value for row in rows}
244
+
245
+ def set_user_preference(self, user_id: str, preference_key: str, value: str) -> bool:
246
+ allowed = {"theme": {"light", "dark"}, "fontSize": {"90", "100", "110"},
247
+ "notification:issueCreated": {"true", "false"},
248
+ "notification:issueUpdated": {"true", "false"}}
249
+ if preference_key not in allowed or value not in allowed[preference_key]:
250
+ return False
251
+ now = datetime.now(timezone.utc).isoformat()
252
+ with self.engine.begin() as conn:
253
+ if not conn.execute(select(user.c.id).where(
254
+ user.c.id == user_id, user.c.active.is_(True),
255
+ user.c.archived_at.is_(None))).first():
256
+ return False
257
+ exists = conn.execute(select(user_preference.c.user_id).where(and_(
258
+ user_preference.c.user_id == user_id,
259
+ user_preference.c.preference_key == preference_key))).first()
260
+ if exists:
261
+ conn.execute(user_preference.update().where(and_(
262
+ user_preference.c.user_id == user_id,
263
+ user_preference.c.preference_key == preference_key)).values(
264
+ value=value, updated_at=now))
265
+ else:
266
+ conn.execute(user_preference.insert().values(
267
+ user_id=user_id, preference_key=preference_key,
268
+ value=value, updated_at=now))
269
+ return True
270
+
271
+ # ------------------------------------------------------------------ auth
272
+ def change_events_after(self, org_id: str, after_seq: int, limit: int = 200,
273
+ viewer_id: str | None = None) -> list[dict]:
274
+ """Events for teams the viewer can see. Without a viewer only team-less events are returned."""
275
+ with self.engine.connect() as conn:
276
+ stmt = select(change_event).where(and_(
277
+ change_event.c.organization_id == org_id, change_event.c.seq > after_seq))
278
+ if viewer_id is None:
279
+ stmt = stmt.where(and_(change_event.c.team_id.is_(None),
280
+ change_event.c.recipient_id.is_(None)))
281
+ else:
282
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, change_event.c.team_id, nullable=True)
283
+ # Per-user events (notifications) reach only their recipient.
284
+ stmt = stmt.where(or_(change_event.c.recipient_id.is_(None),
285
+ change_event.c.recipient_id == viewer_id))
286
+ rows = conn.execute(stmt.order_by(change_event.c.seq).limit(limit)).mappings().all()
287
+ return [change_event_dict(r) for r in rows]
288
+
289
+ def change_org_ids_after(self, after_seq: int, limit: int = 1000) -> list[dict]:
290
+ """Cheap global change scan for the realtime broadcaster: seq + org only."""
291
+ with self.engine.connect() as conn:
292
+ rows = conn.execute(
293
+ select(change_event.c.seq, change_event.c.organization_id)
294
+ .where(change_event.c.seq > after_seq)
295
+ .order_by(change_event.c.seq)
296
+ .limit(limit)
297
+ ).all()
298
+ return [{"seq": r[0], "organization_id": r[1]} for r in rows]
299
+
300
+ def latest_change_seq(self, org_id: str) -> int:
301
+ """Newest change_event seq for an org (0 when none)."""
302
+ with self.engine.connect() as conn:
303
+ return int(conn.execute(
304
+ select(func.max(change_event.c.seq))
305
+ .where(change_event.c.organization_id == org_id)
306
+ ).scalar() or 0)
307
+
308
+ def change_events_after_global(self, after_seq: int, limit: int = 5000) -> list[dict]:
309
+ """All orgs' raw change events after seq (broadcaster feed)."""
310
+ with self.engine.connect() as conn:
311
+ rows = conn.execute(
312
+ select(change_event)
313
+ .where(change_event.c.seq > after_seq)
314
+ .order_by(change_event.c.seq)
315
+ .limit(limit)
316
+ ).mappings().all()
317
+ return [change_event_dict(r, with_org=True) for r in rows]
318
+
319
+ def visible_team_ids(self, org_id: str, viewer_id: str) -> frozenset | None:
320
+ """Viewer-visible team ids, or None when unrestricted (admin)."""
321
+ with self.engine.connect() as conn:
322
+ if _viewer_is_admin(conn, org_id, viewer_id):
323
+ return None
324
+ rows = conn.execute(visible_team_select(org_id, viewer_id)).all()
325
+ return frozenset(r[0] for r in rows)
326
+
327
+ def max_change_seq(self) -> int:
328
+ """Newest change_event seq across every org (0 when none)."""
329
+ with self.engine.connect() as conn:
330
+ return int(conn.execute(select(func.max(change_event.c.seq))).scalar() or 0)
331
+
332
+ def change_events_after_orgs(self, heads: dict[str, int], limit: int = 5000) -> list[dict]:
333
+ """Raw events newer than a per-org cursor (gap-free when seqs are commit-ordered per org)."""
334
+ out: list[dict] = []
335
+ items = list(heads.items())
336
+ with self.engine.connect() as conn:
337
+ for start in range(0, len(items), 200):
338
+ chunk = items[start:start + 200]
339
+ clause = or_(*[and_(change_event.c.organization_id == org,
340
+ change_event.c.seq > seq) for org, seq in chunk])
341
+ rows = conn.execute(select(change_event).where(clause)
342
+ .order_by(change_event.c.seq).limit(limit)).mappings().all()
343
+ out.extend(change_event_dict(r, with_org=True) for r in rows)
344
+ out.sort(key=lambda e: e["seq"])
345
+ return out
346
+
347
+ def visibility_map(self, org_id: str, user_ids) -> dict[str, frozenset | None]:
348
+ """visible_team_ids for many viewers with three queries instead of one per viewer.
349
+
350
+ Mirrors ``visibility_holds`` exactly: admins see everything (None); everyone
351
+ else sees public teams, the teams they hold a membership row on, and restricted
352
+ teams whose parent they belong to.
353
+ """
354
+ uids = list(dict.fromkeys(user_ids))
355
+ if not uids:
356
+ return {}
357
+ with self.engine.connect() as conn:
358
+ admins: set[str] = set()
359
+ known: set[str] = set()
360
+ for start in range(0, len(uids), 500):
361
+ chunk = uids[start:start + 500]
362
+ for uid, admin in conn.execute(select(user.c.id, user.c.admin).where(and_(
363
+ user.c.organization_id == org_id, user.c.id.in_(chunk)))):
364
+ known.add(uid)
365
+ if admin:
366
+ admins.add(uid)
367
+ public = frozenset(r[0] for r in conn.execute(select(team.c.id).where(and_(
368
+ team.c.organization_id == org_id, team.c.visibility == PUBLIC))))
369
+ restricted_parent: dict[str, str] = {
370
+ tid: parent for tid, parent in conn.execute(select(team.c.id, team.c.parent_team_id).where(and_(
371
+ team.c.organization_id == org_id, team.c.visibility == RESTRICTED,
372
+ team.c.parent_team_id.is_not(None))))
373
+ }
374
+ member_of: dict[str, set[str]] = {}
375
+ for start in range(0, len(uids), 500):
376
+ chunk = uids[start:start + 500]
377
+ for uid, tid in conn.execute(
378
+ select(team_member.c.user_id, team_member.c.team_id)
379
+ .join(team, team.c.id == team_member.c.team_id)
380
+ .where(and_(team.c.organization_id == org_id,
381
+ team_member.c.user_id.in_(chunk)))):
382
+ member_of.setdefault(uid, set()).add(tid)
383
+ visible_restricted: dict[str, set[str]] = {}
384
+ for tid, parent in restricted_parent.items():
385
+ for uid in uids:
386
+ if parent in member_of.get(uid, ()):
387
+ visible_restricted.setdefault(uid, set()).add(tid)
388
+ out: dict[str, frozenset | None] = {}
389
+ for uid in uids:
390
+ if uid in admins:
391
+ out[uid] = None
392
+ elif uid not in known:
393
+ out[uid] = frozenset()
394
+ else:
395
+ out[uid] = (public | frozenset(member_of.get(uid, ()))
396
+ | frozenset(visible_restricted.get(uid, ())))
397
+ return out
398
+
399
+ def validate_credentials(self, key_hashes, session_hashes) -> dict[tuple[str, str], tuple[str, str]]:
400
+ """Still-valid credentials → (user_id, organization_id), in a couple of batched queries.
401
+
402
+ Used to revalidate every open realtime socket at once instead of one auth
403
+ lookup (and api-key touch write) per socket per interval.
404
+ """
405
+ now = datetime.now(timezone.utc).isoformat()
406
+ out: dict[tuple[str, str], tuple[str, str]] = {}
407
+ keys, sessions = list(set(key_hashes)), list(set(session_hashes))
408
+ with self.engine.connect() as conn:
409
+ for start in range(0, len(keys), 500):
410
+ chunk = keys[start:start + 500]
411
+ for th, uid, org in conn.execute(
412
+ select(api_key.c.token_hash, api_key.c.user_id, api_key.c.organization_id)
413
+ .join(user, and_(user.c.id == api_key.c.user_id,
414
+ user.c.organization_id == api_key.c.organization_id))
415
+ .where(and_(api_key.c.token_hash.in_(chunk), api_key.c.revoked_at.is_(None),
416
+ or_(api_key.c.expires_at.is_(None), api_key.c.expires_at > now),
417
+ user.c.active.is_(True), user.c.archived_at.is_(None)))):
418
+ out[("key", th)] = (uid, org)
419
+ for start in range(0, len(sessions), 500):
420
+ chunk = sessions[start:start + 500]
421
+ for th, uid, org in conn.execute(
422
+ select(browser_session.c.token_hash, browser_session.c.user_id,
423
+ browser_session.c.organization_id)
424
+ .join(user, and_(user.c.id == browser_session.c.user_id,
425
+ user.c.organization_id == browser_session.c.organization_id))
426
+ .join(organization_membership, and_(
427
+ organization_membership.c.profile_user_id == browser_session.c.user_id,
428
+ organization_membership.c.organization_id == browser_session.c.organization_id,
429
+ organization_membership.c.active.is_(True)))
430
+ .where(and_(browser_session.c.token_hash.in_(chunk),
431
+ browser_session.c.revoked_at.is_(None),
432
+ browser_session.c.expires_at > now,
433
+ user.c.active.is_(True), user.c.archived_at.is_(None)))):
434
+ out[("session", th)] = (uid, org)
435
+ return out
436
+
437
+ def resolve_token(self, token: str) -> dict | None:
438
+ """token → {user_id, organization_id} or None."""
439
+ th = token_hash(token)
440
+ with self.engine.connect() as conn:
441
+ row = _one(
442
+ conn,
443
+ select(api_key)
444
+ .join(
445
+ user,
446
+ and_(
447
+ user.c.id == api_key.c.user_id,
448
+ user.c.organization_id == api_key.c.organization_id,
449
+ ),
450
+ )
451
+ .where(
452
+ and_(
453
+ api_key.c.token_hash == th,
454
+ api_key.c.revoked_at.is_(None),
455
+ or_(api_key.c.expires_at.is_(None),
456
+ api_key.c.expires_at > datetime.now(timezone.utc).isoformat()),
457
+ user.c.active.is_(True),
458
+ user.c.archived_at.is_(None),
459
+ )
460
+ ),
461
+ )
462
+ return row
463
+
464
+ def touch_api_key(self, key_id: str, last_used_at: str | None = None) -> None:
465
+ """Record use, at most once per hour, to avoid a write on every request."""
466
+ now = datetime.now(timezone.utc)
467
+ threshold = (now - timedelta(hours=1)).isoformat()
468
+ if last_used_at is not None and last_used_at >= threshold:
469
+ return # recent enough: skip the write transaction (a global lock on SQLite)
470
+ with self.engine.begin() as conn:
471
+ conn.execute(api_key.update().where(and_(
472
+ api_key.c.id == key_id,
473
+ or_(api_key.c.last_used_at.is_(None), api_key.c.last_used_at < threshold),
474
+ )).values(last_used_at=now.isoformat()))
475
+
476
+ def list_api_keys(self, org_id: str, user_id: str) -> list[dict]:
477
+ """Return metadata for this user's keys in the active workspace only."""
478
+ with self.engine.connect() as conn:
479
+ return _conn_rows(conn, select(
480
+ api_key.c.id, api_key.c.label, api_key.c.access, api_key.c.hint,
481
+ api_key.c.expires_at.label("expiresAt"),
482
+ api_key.c.last_used_at.label("lastUsedAt"),
483
+ api_key.c.created_at.label("createdAt"),
484
+ api_key.c.revoked_at.label("revokedAt"),
485
+ ).where(and_(api_key.c.organization_id == org_id,
486
+ api_key.c.user_id == user_id)).order_by(api_key.c.created_at))
487
+
488
+ def create_api_key(self, org_id: str, user_id: str, label: str, access: str,
489
+ expires_at: str | None = None) -> tuple[dict, str] | None:
490
+ from hoja_server.db import gen_token
491
+ clean_label = label.strip()
492
+ if not clean_label or len(clean_label) > 80:
493
+ return None
494
+ if access not in ("read", "read_write"):
495
+ return None
496
+ expires_at = _normalize_expiry(expires_at)
497
+ if expires_at is False:
498
+ return None
499
+ raw_token = gen_token()
500
+ key_id = new_id()
501
+ now = datetime.now(timezone.utc).isoformat()
502
+ with self.engine.begin() as conn:
503
+ if not conn.execute(select(user.c.id).where(and_(
504
+ user.c.id == user_id, user.c.organization_id == org_id,
505
+ user.c.active.is_(True), user.c.archived_at.is_(None)))).first():
506
+ return None
507
+ conn.execute(api_key.insert().values(
508
+ id=key_id, token_hash=token_hash(raw_token), label=clean_label,
509
+ hint=raw_token[-4:], access=access, expires_at=expires_at,
510
+ user_id=user_id, organization_id=org_id, created_at=now))
511
+ return {"id": key_id, "label": clean_label, "access": access, "hint": raw_token[-4:],
512
+ "expiresAt": expires_at, "lastUsedAt": None,
513
+ "createdAt": now, "revokedAt": None}, raw_token
514
+
515
+ def revoke_api_key(self, org_id: str, user_id: str, key_id: str) -> bool:
516
+ with self.engine.begin() as conn:
517
+ result = conn.execute(api_key.update().where(and_(
518
+ api_key.c.id == key_id, api_key.c.organization_id == org_id,
519
+ api_key.c.user_id == user_id, api_key.c.revoked_at.is_(None),
520
+ )).values(revoked_at=datetime.now(timezone.utc).isoformat()))
521
+ return bool(result.rowcount)
522
+
523
+ def create_browser_session_for_token(self, token: str, days: int = 14) -> tuple[str, dict] | None:
524
+ """Create a browser session for the owner of a valid API key."""
525
+ identity = self.resolve_token(token)
526
+ if not identity:
527
+ return None
528
+ raw = secrets.token_urlsafe(48)
529
+ now = datetime.now(timezone.utc)
530
+ expires = now + timedelta(days=days)
531
+ with self.engine.begin() as conn:
532
+ conn.execute(browser_session.insert().values(
533
+ id=new_id(), token_hash=token_hash(raw), user_id=identity["user_id"],
534
+ organization_id=identity["organization_id"], created_at=now.isoformat(),
535
+ expires_at=expires.isoformat()))
536
+ return raw, {"user_id": identity["user_id"],
537
+ "organization_id": identity["organization_id"],
538
+ "expires_at": expires.isoformat()}
539
+
540
+ @staticmethod
541
+ def _password_digest(password: str, salt: bytes) -> str:
542
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _PBKDF2_ITERATIONS)
543
+ return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
544
+
545
+ @staticmethod
546
+ def _verify_password(password: str, stored) -> bool:
547
+ """Verify iteration-tagged hashes and legacy 3-part hashes (implicit 310k iterations)."""
548
+ parts = stored.split("$") if isinstance(stored, str) else []
549
+ try:
550
+ if len(parts) == 4 and parts[0] == "pbkdf2_sha256":
551
+ iterations, salt_hex, digest_hex = int(parts[1]), parts[2], parts[3]
552
+ elif len(parts) == 3 and parts[0] == "pbkdf2_sha256":
553
+ iterations, salt_hex, digest_hex = _LEGACY_PBKDF2_ITERATIONS, parts[1], parts[2]
554
+ else:
555
+ return False
556
+ if not 1 <= iterations <= 10_000_000:
557
+ return False
558
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), iterations)
559
+ return hmac.compare_digest(digest, bytes.fromhex(digest_hex))
560
+ except (ValueError, TypeError):
561
+ return False
562
+
563
+ @staticmethod
564
+ def _reject_unverified(password: str) -> None:
565
+ """Burn one PBKDF2 round when no credential exists, so unknown accounts take as long as wrong passwords."""
566
+ hashlib.pbkdf2_hmac("sha256", password.encode(), b"\x00" * 16, _PBKDF2_ITERATIONS)
567
+
568
+ def set_password(self, api_token: str, password: str) -> bool:
569
+ if len(password) < 12:
570
+ raise ValueError("Password must be at least 12 characters.")
571
+ identity = self.resolve_token(api_token)
572
+ if not identity:
573
+ return False
574
+ salt = secrets.token_bytes(16)
575
+ encoded = self._password_digest(password, salt)
576
+ now = datetime.now(timezone.utc)
577
+ with self.engine.begin() as conn:
578
+ member = conn.execute(select(organization_membership.c.identity_id).where(
579
+ organization_membership.c.profile_user_id == identity["user_id"])).first()
580
+ if member:
581
+ existing = conn.execute(select(identity_credential.c.identity_id).where(
582
+ identity_credential.c.identity_id == member[0])).first()
583
+ values = {"identity_id": member[0], "password_hash": encoded,
584
+ "created_at": now.isoformat(), "updated_at": now.isoformat()}
585
+ credential_table = identity_credential
586
+ key_col = identity_credential.c.identity_id
587
+ credential_key = member[0]
588
+ else:
589
+ existing = conn.execute(select(password_credential.c.user_id).where(
590
+ password_credential.c.user_id == identity["user_id"])).first()
591
+ values = {"user_id": identity["user_id"], "password_hash": encoded,
592
+ "created_at": now.isoformat(), "updated_at": now.isoformat()}
593
+ credential_table = password_credential
594
+ key_col = password_credential.c.user_id
595
+ credential_key = identity["user_id"]
596
+ if existing:
597
+ conn.execute(credential_table.update().where(
598
+ key_col == credential_key).values(
599
+ password_hash=encoded, updated_at=now.isoformat()))
600
+ else:
601
+ conn.execute(credential_table.insert().values(**values))
602
+ # A password change ends every browser session for this identity's profiles.
603
+ profile_ids = ([row[0] for row in conn.execute(select(organization_membership.c.profile_user_id).where(
604
+ organization_membership.c.identity_id == member[0]))]
605
+ if member else [identity["user_id"]])
606
+ conn.execute(browser_session.update().where(and_(
607
+ browser_session.c.user_id.in_(profile_ids),
608
+ browser_session.c.revoked_at.is_(None))).values(revoked_at=now.isoformat()))
609
+ return True
610
+
611
+ def workspaces_for_email(self, email: str) -> list[dict]:
612
+ with self.engine.connect() as conn:
613
+ rows = conn.execute(select(
614
+ organization_membership.c.identity_id,
615
+ organization_membership.c.organization_id,
616
+ organization_membership.c.profile_user_id,
617
+ organization.c.name, organization.c.url_key,
618
+ user.c.email, user.c.name.label("profile_name"), user.c.admin,
619
+ ).join(global_identity, global_identity.c.id == organization_membership.c.identity_id)
620
+ .join(organization, organization.c.id == organization_membership.c.organization_id)
621
+ .join(user, and_(user.c.id == organization_membership.c.profile_user_id,
622
+ user.c.organization_id == organization_membership.c.organization_id))
623
+ .where(and_(global_identity.c.email == email.strip().lower(),
624
+ organization_membership.c.active.is_(True),
625
+ user.c.active.is_(True), user.c.archived_at.is_(None)))).mappings().all()
626
+ return [dict(row) for row in rows]
627
+
628
+ def matching_login_workspaces(self, email: str) -> list[dict]:
629
+ """Return selectable workspaces; credential verification happens after selection."""
630
+ return [{"organizationId": item["organization_id"],
631
+ "name": item["name"], "urlKey": item["url_key"]}
632
+ for item in self.workspaces_for_email(email)]
633
+
634
+ def create_session_for_membership(self, identity_id: str, profile_user_id: str,
635
+ organization_id: str, days: int = 14) -> tuple[str, dict] | tuple[None, None]:
636
+ selected = self.switch_membership(identity_id, organization_id)
637
+ if not selected or selected["user_id"] != profile_user_id:
638
+ return None, None
639
+ raw = secrets.token_urlsafe(48)
640
+ now = datetime.now(timezone.utc)
641
+ expires = now + timedelta(days=days)
642
+ with self.engine.begin() as conn:
643
+ conn.execute(browser_session.insert().values(
644
+ id=new_id(), token_hash=token_hash(raw), user_id=profile_user_id,
645
+ organization_id=organization_id, created_at=now.isoformat(),
646
+ expires_at=expires.isoformat()))
647
+ return raw, {"expires_at": expires.isoformat(), "user_id": profile_user_id,
648
+ "organization_id": organization_id, "identity_id": identity_id}
649
+
650
+ def create_browser_session(self, email: str, password: str, *, organization_ref: str | None = None, days: int = 14) -> tuple[str, dict] | None:
651
+ candidates = self.workspaces_for_email(email)
652
+ if organization_ref:
653
+ selected_workspace = next((r for r in candidates if
654
+ r["organization_id"] == organization_ref or r["url_key"] == organization_ref), None)
655
+ if not selected_workspace:
656
+ return None
657
+ else:
658
+ selected_workspace = candidates[0] if len(candidates) == 1 else None
659
+ if len(candidates) != 1:
660
+ # A shared identity credential authenticates once; an explicit
661
+ # workspace selects the profile only after membership is verified.
662
+ if not selected_workspace:
663
+ if candidates or not organization_ref:
664
+ self._reject_unverified(password)
665
+ return None
666
+ else:
667
+ identity_id = selected_workspace["identity_id"]
668
+ with self.engine.connect() as conn:
669
+ credential = conn.execute(select(identity_credential).where(
670
+ identity_credential.c.identity_id == identity_id
671
+ )).mappings().first()
672
+ if not credential:
673
+ self._reject_unverified(password)
674
+ return None
675
+ if not self._verify_password(password, credential["password_hash"]):
676
+ return None
677
+ raw = secrets.token_urlsafe(48)
678
+ now = datetime.now(timezone.utc)
679
+ expires = now + timedelta(days=days)
680
+ with self.engine.begin() as conn:
681
+ conn.execute(browser_session.insert().values(
682
+ id=new_id(), token_hash=token_hash(raw),
683
+ user_id=selected_workspace["profile_user_id"],
684
+ organization_id=selected_workspace["organization_id"],
685
+ created_at=now.isoformat(), expires_at=expires.isoformat()))
686
+ return raw, {"user_id": selected_workspace["profile_user_id"],
687
+ "identity_id": identity_id,
688
+ "organization_id": selected_workspace["organization_id"],
689
+ "expires_at": expires.isoformat()}
690
+ # Pre-revision-6 fallback only; duplicate identities remain isolated.
691
+ with self.engine.connect() as conn:
692
+ legacy = conn.execute(select(password_credential.c.password_hash,
693
+ user.c.id.label("profile_user_id"), user.c.organization_id,
694
+ user.c.email, organization.c.url_key)
695
+ .join(user, user.c.id == password_credential.c.user_id)
696
+ .join(organization, organization.c.id == user.c.organization_id)
697
+ .where(and_(func.lower(user.c.email) == email.strip().lower(), user.c.active.is_(True),
698
+ user.c.archived_at.is_(None),
699
+ or_(user.c.organization_id == organization_ref,
700
+ organization.c.url_key == organization_ref)))).mappings().first()
701
+ if not legacy:
702
+ self._reject_unverified(password)
703
+ return None
704
+ if not self._verify_password(password, legacy["password_hash"]):
705
+ return None
706
+ raw = secrets.token_urlsafe(48)
707
+ now = datetime.now(timezone.utc)
708
+ expires = now + timedelta(days=days)
709
+ with self.engine.begin() as conn:
710
+ conn.execute(browser_session.insert().values(
711
+ id=new_id(), token_hash=token_hash(raw), user_id=legacy["profile_user_id"],
712
+ organization_id=legacy["organization_id"], created_at=now.isoformat(),
713
+ expires_at=expires.isoformat()))
714
+ return raw, {"user_id": legacy["profile_user_id"],
715
+ "organization_id": legacy["organization_id"], "expires_at": expires.isoformat()}
716
+ selected = selected_workspace or candidates[0]
717
+ with self.engine.connect() as conn:
718
+ credential = conn.execute(select(identity_credential).where(
719
+ identity_credential.c.identity_id == selected["identity_id"])).mappings().first()
720
+ if not credential:
721
+ # Backward-compatible fallback before migration/identity setup.
722
+ credential = conn.execute(select(password_credential).where(
723
+ password_credential.c.user_id == selected["profile_user_id"])).mappings().first()
724
+ if not credential:
725
+ self._reject_unverified(password)
726
+ return None
727
+ if not self._verify_password(password, credential["password_hash"]):
728
+ return None
729
+ raw = secrets.token_urlsafe(48)
730
+ now = datetime.now(timezone.utc)
731
+ expires = now + timedelta(days=days)
732
+ with self.engine.begin() as conn:
733
+ conn.execute(browser_session.insert().values(
734
+ id=new_id(), token_hash=token_hash(raw), user_id=selected["profile_user_id"],
735
+ organization_id=selected["organization_id"], created_at=now.isoformat(),
736
+ expires_at=expires.isoformat()))
737
+ return raw, {"user_id": selected["profile_user_id"], "identity_id": selected["identity_id"],
738
+ "organization_id": selected["organization_id"], "expires_at": expires.isoformat()}
739
+
740
+ def resolve_browser_session(self, raw: str) -> dict | None:
741
+ now = datetime.now(timezone.utc).isoformat()
742
+ with self.engine.connect() as conn:
743
+ session = _one(conn, select(browser_session).join(user, and_(
744
+ user.c.id == browser_session.c.user_id,
745
+ user.c.organization_id == browser_session.c.organization_id))
746
+ .where(and_(browser_session.c.token_hash == token_hash(raw),
747
+ browser_session.c.revoked_at.is_(None), browser_session.c.expires_at > now,
748
+ user.c.active.is_(True), user.c.archived_at.is_(None))))
749
+ if not session:
750
+ return None
751
+ membership = _one(conn, select(organization_membership).where(and_(
752
+ organization_membership.c.profile_user_id == session["user_id"],
753
+ organization_membership.c.organization_id == session["organization_id"],
754
+ organization_membership.c.active.is_(True))))
755
+ if not membership:
756
+ return None
757
+ session["identity_id"] = membership["identity_id"]
758
+ return session
759
+
760
+ def revoke_browser_session(self, raw: str) -> None:
761
+ with self.engine.begin() as conn:
762
+ conn.execute(browser_session.update().where(and_(
763
+ browser_session.c.token_hash == token_hash(raw),
764
+ browser_session.c.revoked_at.is_(None))).values(
765
+ revoked_at=datetime.now(timezone.utc).isoformat()))
766
+
767
+ # ------------------------------------------------------------ serializers
768
+ def ser_user(self, row: dict | None, ctx_user_id: str | None = None) -> dict | None:
769
+ if not row:
770
+ return None
771
+ return {
772
+ "id": row["id"], "name": row.get("name"), "displayName": row.get("display_name"),
773
+ "email": row.get("email"), "active": bool(row.get("active", True)),
774
+ "isMe": row["id"] == ctx_user_id, "admin": bool(row.get("admin", False)),
775
+ "avatarUrl": row.get("avatar_url"), "url": row.get("url"),
776
+ "timezone": row.get("timezone"), "statusEmoji": row.get("status_emoji"),
777
+ "statusLabel": row.get("status_label"),
778
+ "createdAt": row.get("created_at"), "updatedAt": row.get("updated_at"),
779
+ }
780
+
781
+ def ser_state(self, row: dict | None) -> dict | None:
782
+ if not row:
783
+ return None
784
+ return {
785
+ "id": row["id"], "name": row["name"], "color": row.get("color"),
786
+ "description": row.get("description"), "position": row.get("position"),
787
+ "type": row["type"], "createdAt": row.get("created_at"),
788
+ "updatedAt": row.get("updated_at"), "_team_id": row.get("team_id"),
789
+ }
790
+
791
+ def ser_team(self, row: dict | None) -> dict | None:
792
+ if not row:
793
+ return None
794
+ auto_started = row.get("auto_add_started", True)
795
+ auto_completed = row.get("auto_add_completed", True)
796
+ auto_backlog = row.get("auto_add_backlog", True)
797
+ return {
798
+ "id": row["id"], "name": row["name"], "key": row["key"],
799
+ "description": row.get("description"), "color": row.get("color"),
800
+ "icon": row.get("icon"),
801
+ "visibility": row.get("visibility") or PUBLIC,
802
+ "parentTeamId": row.get("parent_team_id"),
803
+ "timezone": row.get("timezone"), "createdAt": row.get("created_at"),
804
+ "updatedAt": row.get("updated_at"), "_id": row["id"],
805
+ "autoAddStarted": True if auto_started is None else bool(auto_started),
806
+ "autoAddCompleted": True if auto_completed is None else bool(auto_completed),
807
+ "autoAddBacklog": True if auto_backlog is None else bool(auto_backlog),
808
+ "_active_cycle_id": row.get("active_cycle_id"),
809
+ }
810
+
811
+ def ser_label(self, row: dict | None) -> dict | None:
812
+ if not row:
813
+ return None
814
+ return {
815
+ "id": row["id"], "name": row["name"], "color": row.get("color"),
816
+ "description": row.get("description"), "createdAt": row.get("created_at"),
817
+ "updatedAt": row.get("updated_at"),
818
+ }
819
+
820
+ def ser_cycle(self, row: dict | None) -> dict | None:
821
+ if not row:
822
+ return None
823
+ return {
824
+ "id": row["id"], "name": row.get("name"), "number": row.get("number"),
825
+ "description": row.get("description"),
826
+ "startsAt": row.get("starts_at"), "endsAt": row.get("ends_at"),
827
+ "completedAt": row.get("completed_at"), "progress": row.get("progress"),
828
+ "createdAt": row.get("created_at"), "updatedAt": row.get("updated_at"),
829
+ "_team_id": row.get("team_id"),
830
+ }
831
+
832
+ def ser_project(self, row: dict | None) -> dict | None:
833
+ if not row:
834
+ return None
835
+ return {
836
+ "id": row["id"], "name": row["name"], "description": row.get("description"),
837
+ "slugId": row.get("slug_id"), "icon": row.get("icon"), "color": row.get("color"),
838
+ "state": row.get("state"), "progress": row.get("progress"),
839
+ "startDate": row.get("start_date"), "targetDate": row.get("target_date"),
840
+ "url": row.get("url"), "createdAt": row.get("created_at"),
841
+ "updatedAt": row.get("updated_at"),
842
+ "_lead_id": row.get("lead_id"), "_creator_id": row.get("creator_id"),
843
+ }
844
+
845
+ def ser_comment(self, row: dict | None) -> dict | None:
846
+ if not row:
847
+ return None
848
+ return {
849
+ "id": row["id"], "body": row["body"], "url": row.get("url"),
850
+ "createdAt": row.get("created_at"), "updatedAt": row.get("updated_at"),
851
+ "editedAt": row.get("updated_at") if (row.get("updated_at") or "") > (row.get("created_at") or "") else None,
852
+ "_user_id": row.get("user_id"),
853
+ "_archived": row.get("archived_at") is not None,
854
+ }
855
+
856
+ def ser_attachment(self, row: dict | None) -> dict | None:
857
+ if not row:
858
+ return None
859
+ return {
860
+ "id": row["id"], "url": row.get("url"), "title": row.get("title"),
861
+ "subtitle": row.get("subtitle"), "createdAt": row.get("created_at"),
862
+ "_issue_id": row.get("issue_id"),
863
+ }
864
+
865
+ def ser_issue(self, row: dict | None) -> dict | None:
866
+ if not row:
867
+ return None
868
+ return {
869
+ "id": row["id"], "identifier": row["identifier"], "title": row["title"],
870
+ "description": row.get("description"), "priority": row.get("priority", 0),
871
+ "priorityLabel": row.get("priority_label"), "estimate": row.get("estimate"),
872
+ "url": row.get("url"), "branchName": row.get("branch_name"),
873
+ "number": row.get("number"), "createdAt": row.get("created_at"),
874
+ "updatedAt": row.get("updated_at"), "dueDate": row.get("due_date"),
875
+ "completedAt": row.get("completed_at"), "canceledAt": row.get("canceled_at"),
876
+ "startedAt": row.get("started_at"), "snoozedUntilAt": row.get("snoozed_until_at"),
877
+ "stateChangedAt": row.get("state_changed_at"),
878
+ # ids for lazy field resolvers
879
+ "_state_id": row.get("state_id"), "_assignee_id": row.get("assignee_id"),
880
+ "_creator_id": row.get("creator_id"), "_team_id": row.get("team_id"),
881
+ "_project_id": row.get("project_id"), "_cycle_id": row.get("cycle_id"),
882
+ "_parent_id": row.get("parent_id"), "_org_id": row.get("organization_id"),
883
+ }
884
+
885
+ # ------------------------------------------------------------------ reads
886
+ def get_user(self, org_id: str, user_id: str) -> dict | None:
887
+ with self.engine.connect() as conn:
888
+ return _one(conn, select(user).where(
889
+ and_(user.c.organization_id == org_id, user.c.id == user_id)))
890
+
891
+ def viewer(self, org_id: str, user_id: str) -> dict | None:
892
+ return self.ser_user(self.get_user(org_id, user_id), user_id)
893
+
894
+ def membership_for_profile(self, org_id: str, user_id: str) -> dict | None:
895
+ with self.engine.connect() as conn:
896
+ return _one(conn, select(organization_membership).where(and_(
897
+ organization_membership.c.organization_id == org_id,
898
+ organization_membership.c.profile_user_id == user_id,
899
+ organization_membership.c.active.is_(True))))
900
+
901
+ def identity_workspaces(self, identity_id: str) -> list[dict]:
902
+ with self.engine.connect() as conn:
903
+ rows = conn.execute(select(
904
+ organization_membership.c.id,
905
+ organization_membership.c.organization_id.label("organizationId"),
906
+ organization.c.name, organization.c.url_key.label("urlKey"),
907
+ user.c.admin,
908
+ ).join(organization, organization.c.id == organization_membership.c.organization_id)
909
+ .join(user, and_(user.c.id == organization_membership.c.profile_user_id,
910
+ user.c.organization_id == organization_membership.c.organization_id))
911
+ .where(and_(organization_membership.c.identity_id == identity_id,
912
+ organization_membership.c.active.is_(True),
913
+ user.c.active.is_(True), user.c.archived_at.is_(None)))
914
+ .order_by(organization.c.name)).mappings().all()
915
+ return [{"id": r["id"], "organizationId": r["organizationId"],
916
+ "name": r["name"], "urlKey": r["urlKey"],
917
+ "role": "admin" if r["admin"] else "member"} for r in rows]
918
+
919
+ def switch_membership(self, identity_id: str | None, organization_ref: str) -> dict | None:
920
+ if not identity_id:
921
+ return None
922
+ with self.engine.connect() as conn:
923
+ row = conn.execute(select(
924
+ organization_membership.c.profile_user_id,
925
+ organization_membership.c.organization_id,
926
+ organization.c.id.label("id"), organization.c.name,
927
+ organization.c.url_key.label("urlKey"),
928
+ ).join(organization, organization.c.id == organization_membership.c.organization_id)
929
+ .join(user, and_(user.c.id == organization_membership.c.profile_user_id,
930
+ user.c.organization_id == organization_membership.c.organization_id))
931
+ .where(and_(organization_membership.c.identity_id == identity_id,
932
+ organization.c.id == organization_ref,
933
+ organization_membership.c.active.is_(True),
934
+ user.c.active.is_(True), user.c.archived_at.is_(None)))).mappings().first()
935
+ if not row:
936
+ return None
937
+ return {"user_id": row["profile_user_id"], "organization_id": row["organization_id"],
938
+ "organization": {"id": row["id"], "name": row["name"], "urlKey": row["urlKey"]}}
939
+
940
+ def create_membership_invitation(self, org_id: str, profile_user_id: str,
941
+ creator_profile_id: str, hours: int = 72) -> str | None:
942
+ raw = secrets.token_urlsafe(32)
943
+ now = datetime.now(timezone.utc)
944
+ with self.engine.begin() as conn:
945
+ valid = conn.execute(select(user.c.id).where(and_(
946
+ user.c.id == profile_user_id, user.c.organization_id == org_id,
947
+ user.c.active.is_(True), user.c.archived_at.is_(None),
948
+ ))).first()
949
+ if not valid:
950
+ return None
951
+ # Replace any still-pending invitation for this profile so it remains one-time.
952
+ conn.execute(membership_invitation.delete().where(and_(
953
+ membership_invitation.c.profile_user_id == profile_user_id,
954
+ membership_invitation.c.accepted_at.is_(None),
955
+ )))
956
+ conn.execute(membership_invitation.insert().values(
957
+ id=new_id(), token_hash=token_hash(raw), organization_id=org_id,
958
+ profile_user_id=profile_user_id, created_by_profile_id=creator_profile_id,
959
+ created_at=now.isoformat(),
960
+ expires_at=(now + timedelta(hours=hours)).isoformat()))
961
+ return raw
962
+
963
+ def pending_membership_invitations(self, org_id: str) -> list[dict]:
964
+ now = datetime.now(timezone.utc).isoformat()
965
+ with self.engine.connect() as conn:
966
+ stmt = select(user.c.id, user.c.name, user.c.email, membership_invitation.c.expires_at).select_from(
967
+ membership_invitation.join(user, user.c.id == membership_invitation.c.profile_user_id)
968
+ ).where(and_(
969
+ membership_invitation.c.organization_id == org_id,
970
+ membership_invitation.c.accepted_at.is_(None),
971
+ membership_invitation.c.expires_at > now,
972
+ )).order_by(user.c.name)
973
+ return [{"userId": r.id, "name": r.name, "email": r.email, "expiresAt": r.expires_at}
974
+ for r in conn.execute(stmt).mappings().all()]
975
+
976
+ def revoke_membership_invitation(self, org_id: str, profile_user_id: str) -> bool:
977
+ with self.engine.begin() as conn:
978
+ result = conn.execute(membership_invitation.delete().where(and_(
979
+ membership_invitation.c.organization_id == org_id,
980
+ membership_invitation.c.profile_user_id == profile_user_id,
981
+ membership_invitation.c.accepted_at.is_(None),
982
+ )))
983
+ return result.rowcount > 0
984
+
985
+ def set_member_active(self, org_id: str, actor_id: str, user_id: str, active: bool) -> dict | None:
986
+ """Deactivate or reactivate a member. Refuses to strand the workspace."""
987
+ now = datetime.now(timezone.utc).isoformat()
988
+ with self.engine.begin() as conn:
989
+ target = conn.execute(select(user.c.id, user.c.admin, user.c.active).where(and_(
990
+ user.c.organization_id == org_id, user.c.id == user_id,
991
+ user.c.archived_at.is_(None),
992
+ ))).first()
993
+ if not target:
994
+ return None
995
+ if not active:
996
+ if user_id == actor_id:
997
+ return None
998
+ if target.admin and target.active:
999
+ other_admins = conn.execute(select(user.c.id).where(and_(
1000
+ user.c.organization_id == org_id, user.c.admin.is_(True),
1001
+ user.c.active.is_(True), user.c.archived_at.is_(None),
1002
+ user.c.id != user_id,
1003
+ )).with_for_update()).all()
1004
+ if not other_admins:
1005
+ return None
1006
+ conn.execute(user.update().where(user.c.id == user_id).values(
1007
+ active=bool(active), updated_at=now))
1008
+ return self.get_user(org_id, user_id)
1009
+
1010
+ def accept_membership_invitation(self, identity_id: str, raw: str,
1011
+ invitee_password: str) -> dict | None:
1012
+ now = datetime.now(timezone.utc).isoformat()
1013
+ with self.engine.begin() as conn:
1014
+ invite = conn.execute(select(membership_invitation).where(and_(
1015
+ membership_invitation.c.token_hash == token_hash(raw),
1016
+ membership_invitation.c.accepted_at.is_(None),
1017
+ membership_invitation.c.expires_at > now,
1018
+ ))).mappings().first()
1019
+ if not invite:
1020
+ return None
1021
+ already = conn.execute(select(organization_membership.c.id).where(and_(
1022
+ organization_membership.c.identity_id == identity_id,
1023
+ organization_membership.c.organization_id == invite["organization_id"],
1024
+ ))).first()
1025
+ if already:
1026
+ return None
1027
+ profile = conn.execute(select(user.c.id).where(and_(
1028
+ user.c.id == invite["profile_user_id"],
1029
+ user.c.organization_id == invite["organization_id"],
1030
+ user.c.active.is_(True), user.c.archived_at.is_(None),
1031
+ ))).first()
1032
+ if not profile:
1033
+ return None
1034
+ identity_email = conn.execute(select(global_identity.c.email).where(
1035
+ global_identity.c.id == identity_id)).scalar_one_or_none()
1036
+ target_identity = conn.execute(select(organization_membership.c.identity_id).where(
1037
+ organization_membership.c.profile_user_id == profile[0])).scalar_one_or_none()
1038
+ target_credential = conn.execute(select(identity_credential).where(
1039
+ identity_credential.c.identity_id == target_identity)).mappings().first() if target_identity else None
1040
+ profile_email = conn.execute(select(user.c.email).where(
1041
+ user.c.id == profile[0])).scalar_one_or_none()
1042
+ if (not identity_email or not profile_email or identity_id == target_identity or
1043
+ identity_email.strip().lower() != profile_email.strip().lower() or
1044
+ not target_credential):
1045
+ return None
1046
+ if not self._verify_password(invitee_password, target_credential["password_hash"]):
1047
+ return None
1048
+ conn.execute(organization_membership.update().where(
1049
+ organization_membership.c.profile_user_id == profile[0]).values(
1050
+ identity_id=identity_id, active=True))
1051
+ conn.execute(membership_invitation.update().where(
1052
+ membership_invitation.c.id == invite["id"]).values(
1053
+ accepted_at=now, accepted_identity_id=identity_id))
1054
+ org_row = conn.execute(select(organization.c.id, organization.c.name,
1055
+ organization.c.url_key).where(
1056
+ organization.c.id == invite["organization_id"])).mappings().first()
1057
+ return {"id": org_row["id"], "name": org_row["name"],
1058
+ "urlKey": org_row["url_key"]} if org_row else None
1059
+
1060
+ def accept_new_member_invitation(self, raw: str, email: str,
1061
+ password: str) -> tuple[str, dict] | None:
1062
+ """Set credentials for a pre-created invited profile and consume its code."""
1063
+ if len(password) < 12:
1064
+ raise ValueError("Password must be at least 12 characters.")
1065
+ now = datetime.now(timezone.utc)
1066
+ salt = secrets.token_bytes(16)
1067
+ digest = self._password_digest(password, salt)
1068
+ identity_id = new_id()
1069
+ session_token = secrets.token_urlsafe(48)
1070
+ normalized_email = email.strip().lower()
1071
+ with self.engine.begin() as conn:
1072
+ invite = conn.execute(select(membership_invitation).where(and_(
1073
+ membership_invitation.c.token_hash == token_hash(raw),
1074
+ membership_invitation.c.accepted_at.is_(None),
1075
+ membership_invitation.c.expires_at > now.isoformat(),
1076
+ ))).mappings().first()
1077
+ if not invite:
1078
+ return None
1079
+ profile = conn.execute(select(user).where(and_(
1080
+ user.c.id == invite["profile_user_id"],
1081
+ user.c.organization_id == invite["organization_id"],
1082
+ user.c.active.is_(True), user.c.archived_at.is_(None),
1083
+ func.lower(user.c.email) == normalized_email,
1084
+ ))).mappings().first()
1085
+ if not profile:
1086
+ return None
1087
+ membership = conn.execute(select(organization_membership).where(and_(
1088
+ organization_membership.c.profile_user_id == profile["id"],
1089
+ organization_membership.c.organization_id == invite["organization_id"],
1090
+ organization_membership.c.active.is_(True),
1091
+ ))).mappings().first()
1092
+ if not membership:
1093
+ return None
1094
+ credential = conn.execute(select(identity_credential).where(
1095
+ identity_credential.c.identity_id == membership["identity_id"])).first()
1096
+ if credential:
1097
+ return None
1098
+ conn.execute(identity_credential.insert().values(
1099
+ identity_id=membership["identity_id"], password_hash=digest,
1100
+ created_at=now.isoformat(), updated_at=now.isoformat()))
1101
+ conn.execute(membership_invitation.update().where(
1102
+ membership_invitation.c.id == invite["id"]).values(
1103
+ accepted_at=now.isoformat(), accepted_identity_id=membership["identity_id"]))
1104
+ expires = now + timedelta(days=14)
1105
+ conn.execute(browser_session.insert().values(
1106
+ id=new_id(), token_hash=token_hash(session_token), user_id=profile["id"],
1107
+ organization_id=invite["organization_id"], created_at=now.isoformat(),
1108
+ expires_at=expires.isoformat()))
1109
+ organization_row = conn.execute(select(organization).where(
1110
+ organization.c.id == invite["organization_id"])).mappings().first()
1111
+ if not organization_row:
1112
+ return None
1113
+ return session_token, {"user_id": profile["id"], "organization_id": invite["organization_id"],
1114
+ "identity_id": membership["identity_id"],
1115
+ "organization": {"id": organization_row["id"],
1116
+ "name": organization_row["name"],
1117
+ "urlKey": organization_row["url_key"]}}
1118
+
1119
+ def organization_members(self, org_id: str) -> list[dict]:
1120
+ with self.engine.connect() as conn:
1121
+ return _conn_rows(conn, select(user).where(and_(
1122
+ user.c.organization_id == org_id,
1123
+ user.c.archived_at.is_(None),
1124
+ )).order_by(user.c.name, user.c.email))
1125
+
1126
+ def set_member_admin_role(self, org_id: str, user_id: str, is_admin: bool) -> dict | None:
1127
+ with self.engine.begin() as conn:
1128
+ target = conn.execute(select(user.c.admin).where(and_(
1129
+ user.c.organization_id == org_id, user.c.id == user_id,
1130
+ user.c.archived_at.is_(None),
1131
+ ))).first()
1132
+ if not target:
1133
+ return None
1134
+ if target[0] and not is_admin:
1135
+ admins = conn.execute(select(user.c.id).where(and_(
1136
+ user.c.organization_id == org_id, user.c.admin.is_(True),
1137
+ user.c.archived_at.is_(None),
1138
+ )).with_for_update()).all()
1139
+ if len(admins) <= 1:
1140
+ return None
1141
+ result = conn.execute(user.update().where(and_(
1142
+ user.c.organization_id == org_id, user.c.id == user_id,
1143
+ user.c.archived_at.is_(None),
1144
+ )).values(admin=bool(is_admin), updated_at=datetime.now(timezone.utc).isoformat()))
1145
+ if not result.rowcount:
1146
+ return None
1147
+ return self.get_user(org_id, user_id)
1148
+
1149
+ def invite_member(self, org_id: str, name: str, email: str, is_admin: bool = False,
1150
+ team_ids: list[str] | None = None) -> tuple[dict, str] | None:
1151
+ from hoja_server.db import api_key as api_key_table, gen_token, new_id, token_hash
1152
+
1153
+ normalized_email = email.strip().lower()
1154
+ clean_name = name.strip()
1155
+ if not clean_name or "@" not in normalized_email:
1156
+ return None
1157
+ raw_token = gen_token()
1158
+ uid = new_id()
1159
+ now = datetime.now(timezone.utc).isoformat()
1160
+ with self.engine.begin() as conn:
1161
+ if conn.execute(select(global_identity.c.id).where(
1162
+ global_identity.c.email == normalized_email)).first():
1163
+ return None
1164
+ if conn.execute(select(user.c.id).where(and_(
1165
+ user.c.organization_id == org_id,
1166
+ func.lower(user.c.email) == normalized_email,
1167
+ user.c.archived_at.is_(None),
1168
+ ))).first():
1169
+ return None
1170
+ conn.execute(user.insert().values(
1171
+ id=uid, organization_id=org_id, name=clean_name, display_name=clean_name,
1172
+ email=normalized_email, active=True, admin=bool(is_admin),
1173
+ created_at=now, updated_at=now,
1174
+ ))
1175
+ identity_id = new_id()
1176
+ conn.execute(global_identity.insert().values(
1177
+ id=identity_id, email=normalized_email, created_at=now))
1178
+ conn.execute(organization_membership.insert().values(
1179
+ id=new_id(), identity_id=identity_id, organization_id=org_id,
1180
+ profile_user_id=uid, active=True, created_at=now))
1181
+ conn.execute(api_key_table.insert().values(
1182
+ id=new_id(), token_hash=token_hash(raw_token), label="member invite",
1183
+ user_id=uid, organization_id=org_id, created_at=now,
1184
+ ))
1185
+ seats: dict[str, None] = {}
1186
+ for team_id in dict.fromkeys(team_ids or []):
1187
+ # Same rule as set_team_membership: a sub-team seat implies its ancestors.
1188
+ for ancestor in self._team_and_ancestors(conn, org_id, team_id):
1189
+ seats[ancestor] = None
1190
+ for team_id in seats:
1191
+ conn.execute(team_member.insert().values(
1192
+ team_id=team_id, user_id=uid, role="member"))
1193
+ return self.get_user(org_id, uid), raw_token
1194
+
1195
+ def workspace(self, org_id: str) -> dict | None:
1196
+ with self.engine.connect() as conn:
1197
+ return _one(conn, select(organization.c.id, organization.c.name,
1198
+ organization.c.url_key.label("urlKey")).where(
1199
+ organization.c.id == org_id))
1200
+
1201
+ def update_workspace(self, org_id: str, values: dict) -> dict | None:
1202
+ changes = {}
1203
+ if values.get("name") is not None:
1204
+ name = values["name"].strip()
1205
+ if not name:
1206
+ return None
1207
+ changes["name"] = name
1208
+ if values.get("urlKey") is not None:
1209
+ url_key = values["urlKey"].strip().lower()
1210
+ if not url_key or not url_key.replace("-", "").isalnum():
1211
+ return None
1212
+ changes["url_key"] = url_key
1213
+ if not changes:
1214
+ return self.workspace(org_id)
1215
+ changes["updated_at"] = datetime.now(timezone.utc).isoformat()
1216
+ with self.engine.begin() as conn:
1217
+ result = conn.execute(organization.update().where(
1218
+ organization.c.id == org_id).values(**changes))
1219
+ if not result.rowcount:
1220
+ return None
1221
+ return self.workspace(org_id)
1222
+
1223
+ def workspace_update(self, org_id: str, values: dict) -> dict | None:
1224
+ """Compatibility alias for the existing workspace settings update."""
1225
+ return self.update_workspace(org_id, values)
1226
+
1227
+ def teams(self, org_id: str, key: str | None = None, viewer_id: str | None = None) -> list[dict]:
1228
+ with self.engine.connect() as conn:
1229
+ stmt = select(team).where(and_(
1230
+ team.c.organization_id == org_id, team.c.archived_at.is_(None)))
1231
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
1232
+ if key:
1233
+ stmt = stmt.where(team.c.key == key.upper())
1234
+ stmt = stmt.order_by(team.c.key)
1235
+ return [self.ser_team(r) for r in _conn_rows(conn, stmt)]
1236
+
1237
+ def team_by_id_or_key(self, org_id: str, ident: str, viewer_id: str | None = None) -> dict | None:
1238
+ with self.engine.connect() as conn:
1239
+ stmt = select(team).where(and_(
1240
+ team.c.organization_id == org_id,
1241
+ or_(team.c.id == ident, team.c.key == ident.upper())))
1242
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
1243
+ return _one(conn, stmt) # raw row (field resolvers need _id / active_cycle_id)
1244
+
1245
+ def team_visible_for(self, org_id: str, viewer_id: str | None, team_id: str | None) -> bool:
1246
+ if not team_id:
1247
+ return True
1248
+ with self.engine.connect() as conn:
1249
+ return team_visible(conn, org_id, viewer_id, team_id)
1250
+
1251
+ def team_states(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
1252
+ with self.engine.connect() as conn:
1253
+ if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
1254
+ return []
1255
+ stmt = select(workflow_state).where(and_(
1256
+ workflow_state.c.organization_id == org_id,
1257
+ workflow_state.c.team_id == team_id,
1258
+ workflow_state.c.archived_at.is_(None))).order_by(workflow_state.c.position)
1259
+ return [self.ser_state(r) for r in _conn_rows(conn, stmt)]
1260
+
1261
+ def workflow_states(self, org_id: str, flt: dict | None = None, viewer_id: str | None = None) -> list[dict]:
1262
+ with self.engine.connect() as conn:
1263
+ stmt = select(workflow_state).where(and_(
1264
+ workflow_state.c.organization_id == org_id,
1265
+ workflow_state.c.archived_at.is_(None),
1266
+ ))
1267
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, workflow_state.c.team_id)
1268
+ if flt:
1269
+ if "team" in flt:
1270
+ team_ids = _resolve_team_ids(conn, org_id, flt["team"])
1271
+ stmt = stmt.where(
1272
+ workflow_state.c.team_id.in_(team_ids or ["__none__"])
1273
+ )
1274
+ for field, column in (
1275
+ ("type", workflow_state.c.type),
1276
+ ("name", workflow_state.c.name),
1277
+ ):
1278
+ if field in flt:
1279
+ clause = _apply_str_cmp(column, flt[field])
1280
+ if clause is not None:
1281
+ stmt = stmt.where(clause)
1282
+ return [
1283
+ self.ser_state(r)
1284
+ for r in _conn_rows(conn, stmt.order_by(workflow_state.c.position))
1285
+ ]
1286
+
1287
+ def set_team_membership(self, org_id: str, team_id: str, user_id: str, *, add: bool,
1288
+ role: str = "member") -> None:
1289
+ """Add or remove a team member, honouring the parent/child membership rules.
1290
+
1291
+ Adding to a sub-team also adds the person to every ancestor, because a
1292
+ department is a reporting structure and a sub-team member belongs to the
1293
+ department above it. Removal never cascades: dropping someone from a parent
1294
+ while they are still on a descendant is refused instead of silently taking
1295
+ away their access to the sub-team.
1296
+ """
1297
+ with self.engine.begin() as conn:
1298
+ if add:
1299
+ if role not in TEAM_ROLES:
1300
+ raise GraphQLError("role must be one of owner, member",
1301
+ extensions={"code": "BAD_USER_INPUT", "field": "role"})
1302
+ for ancestor in self._team_and_ancestors(conn, org_id, team_id):
1303
+ exists = conn.execute(select(team_member.c.user_id).where(and_(
1304
+ team_member.c.team_id == ancestor, team_member.c.user_id == user_id))).first()
1305
+ if not exists:
1306
+ conn.execute(team_member.insert().values(
1307
+ team_id=ancestor, user_id=user_id,
1308
+ role=role if ancestor == team_id else "member"))
1309
+ return
1310
+ descendants = self._team_descendants(conn, org_id, team_id)
1311
+ still_on = [tid for tid in descendants if tid != team_id and conn.execute(
1312
+ select(team_member.c.user_id).where(and_(
1313
+ team_member.c.team_id == tid, team_member.c.user_id == user_id))).first()]
1314
+ if still_on:
1315
+ names = ", ".join(sorted(
1316
+ r[0] for r in conn.execute(select(team.c.key).where(team.c.id.in_(still_on)))))
1317
+ raise GraphQLError(
1318
+ f"This person is still a member of the sub-team(s) {names}. "
1319
+ "Remove them from those first.",
1320
+ extensions={"code": "BAD_USER_INPUT", "field": "teamId",
1321
+ "subTeams": names})
1322
+ conn.execute(team_member.delete().where(and_(
1323
+ team_member.c.team_id == team_id, team_member.c.user_id == user_id)))
1324
+
1325
+ @staticmethod
1326
+ def _team_and_ancestors(conn, org_id: str, team_id: str) -> list[str]:
1327
+ return team_ancestor_ids(conn, org_id, team_id)
1328
+
1329
+ @staticmethod
1330
+ def _team_descendants(conn, org_id: str, team_id: str) -> list[str]:
1331
+ return team_descendant_ids(conn, org_id, team_id)
1332
+
1333
+ def team_members(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
1334
+ with self.engine.connect() as conn:
1335
+ if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
1336
+ return []
1337
+ stmt = select(user).select_from(
1338
+ user.join(team_member, team_member.c.user_id == user.c.id)
1339
+ ).where(and_(team_member.c.team_id == team_id, user.c.organization_id == org_id))
1340
+ return [self.ser_user(r) for r in _conn_rows(conn, stmt)]
1341
+
1342
+ def team_children(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
1343
+ """Direct sub-teams, scoped like every other team read."""
1344
+ with self.engine.connect() as conn:
1345
+ if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
1346
+ return []
1347
+ stmt = select(team).where(and_(
1348
+ team.c.organization_id == org_id,
1349
+ team.c.parent_team_id == team_id,
1350
+ team.c.archived_at.is_(None)))
1351
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
1352
+ return [self.ser_team(r) for r in _conn_rows(conn, stmt.order_by(team.c.key))]
1353
+
1354
+ def team_cycles(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
1355
+ with self.engine.connect() as conn:
1356
+ if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
1357
+ return []
1358
+ stmt = select(cycle).where(and_(
1359
+ cycle.c.organization_id == org_id, cycle.c.team_id == team_id)).order_by(cycle.c.number)
1360
+ return [self.ser_cycle(r) for r in _conn_rows(conn, stmt)]
1361
+
1362
+ def cycles(self, org_id: str, flt: dict | None = None, viewer_id: str | None = None) -> list[dict]:
1363
+ with self.engine.connect() as conn:
1364
+ stmt = select(cycle).where(cycle.c.organization_id == org_id)
1365
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, cycle.c.team_id)
1366
+ if flt:
1367
+ if "team" in flt:
1368
+ team_ids = _resolve_team_ids(conn, org_id, flt["team"])
1369
+ stmt = stmt.where(cycle.c.team_id.in_(team_ids or ["__none__"]))
1370
+ for field, column in (("id", cycle.c.id), ("name", cycle.c.name)):
1371
+ if field in flt:
1372
+ clause = _apply_str_cmp(column, flt[field])
1373
+ if clause is not None:
1374
+ stmt = stmt.where(clause)
1375
+ cycle_ids = _resolve_cycle_ids(conn, org_id, flt)
1376
+ if cycle_ids is not None:
1377
+ stmt = stmt.where(cycle.c.id.in_(cycle_ids or ["__none__"]))
1378
+ return [
1379
+ self.ser_cycle(r)
1380
+ for r in _conn_rows(conn, stmt.order_by(cycle.c.number))
1381
+ ]
1382
+
1383
+ def team_cadence_snapshot(self, org_id: str, team_id: str, viewer_id: str) -> dict:
1384
+ """Cadence view for read-only API keys: reads persisted state and never reconciles."""
1385
+ with self.engine.connect() as conn:
1386
+ if not team_visible(conn, org_id, viewer_id, team_id):
1387
+ raise ValueError("Team not found in this workspace.")
1388
+ row = _one(conn, select(team).where(and_(team.c.organization_id == org_id, team.c.id == team_id)))
1389
+ cycle_rows = _conn_rows(conn, select(cycle).where(and_(
1390
+ cycle.c.organization_id == org_id, cycle.c.team_id == team_id)).order_by(cycle.c.number))
1391
+ zone_name = row.get("timezone") or "UTC"
1392
+ if not row.get("cadence_enabled"):
1393
+ return {"enabled": False, "weekday": None, "timezone": zone_name, "policyVersion": 1,
1394
+ "activeCycle": None, "upcomingCycles": []}
1395
+ active = next((c for c in cycle_rows if c["id"] == row.get("active_cycle_id")), None)
1396
+ after_number = active["number"] if active else 0
1397
+ upcoming = [c for c in cycle_rows if c["number"] > after_number][:2]
1398
+ return {"enabled": True, "weekday": row.get("cadence_weekday"), "timezone": zone_name,
1399
+ "policyVersion": row.get("cadence_policy_version"),
1400
+ "activeCycle": self.ser_cycle(active), "upcomingCycles": [self.ser_cycle(c) for c in upcoming]}
1401
+
1402
+ def issue_by_id_or_identifier(self, org_id: str, ident: str, viewer_id: str | None = None) -> dict | None:
1403
+ with self.engine.connect() as conn:
1404
+ stmt = select(issue).where(and_(
1405
+ issue.c.organization_id == org_id,
1406
+ or_(issue.c.id == ident, issue.c.identifier == ident.upper())))
1407
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1408
+ return self.ser_issue(_one(conn, stmt))
1409
+
1410
+ def issue_children(
1411
+ self, org_id: str, issue_id: str, viewer_id: str | None = None,
1412
+ after: tuple[str, str] | None = None, limit: int | None = None,
1413
+ ) -> list[dict]:
1414
+ with self.engine.connect() as conn:
1415
+ stmt = select(issue).where(and_(
1416
+ issue.c.organization_id == org_id,
1417
+ issue.c.parent_id == issue_id,
1418
+ issue.c.archived_at.is_(None),
1419
+ ))
1420
+ if after:
1421
+ created_at, cursor_id = after
1422
+ stmt = stmt.where(or_(
1423
+ issue.c.created_at > created_at,
1424
+ and_(issue.c.created_at == created_at, issue.c.id > cursor_id),
1425
+ ))
1426
+ stmt = stmt.order_by(issue.c.created_at, issue.c.id)
1427
+ if limit is not None:
1428
+ stmt = stmt.limit(limit)
1429
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1430
+ return [self.ser_issue(row) for row in _conn_rows(conn, stmt)]
1431
+
1432
+ def inbox_notifications(self, org_id: str, user_id: str, after=None, limit=51,
1433
+ unread_only=False, archived=False) -> list[dict]:
1434
+ with self.engine.connect() as conn:
1435
+ disabled_kinds = [key.removeprefix("notification:") for key, value
1436
+ in self.user_preferences(user_id).items()
1437
+ if key.startswith("notification:") and value == "false"]
1438
+ stmt = select(inbox_notification).where(and_(
1439
+ inbox_notification.c.organization_id == org_id,
1440
+ inbox_notification.c.recipient_id == user_id,
1441
+ inbox_notification.c.archived_at.is_not(None) if archived else inbox_notification.c.archived_at.is_(None),
1442
+ ))
1443
+ if disabled_kinds:
1444
+ stmt = stmt.where(inbox_notification.c.kind.not_in(disabled_kinds))
1445
+ if unread_only:
1446
+ stmt = stmt.where(inbox_notification.c.read_at.is_(None))
1447
+ if after:
1448
+ ts, notification_id = after
1449
+ stmt = stmt.where(inbox_notification.c.created_at <= ts).where(or_(inbox_notification.c.created_at < ts,
1450
+ and_(inbox_notification.c.created_at == ts, inbox_notification.c.id < notification_id)))
1451
+ return _conn_rows(conn, stmt.order_by(
1452
+ inbox_notification.c.created_at.desc(), inbox_notification.c.id.desc()).limit(limit))
1453
+
1454
+ def inbox_unread_count(self, org_id: str, user_id: str) -> int:
1455
+ from sqlalchemy import func
1456
+ with self.engine.connect() as conn:
1457
+ conditions = [
1458
+ inbox_notification.c.organization_id == org_id,
1459
+ inbox_notification.c.recipient_id == user_id,
1460
+ inbox_notification.c.read_at.is_(None),
1461
+ inbox_notification.c.archived_at.is_(None),
1462
+ ]
1463
+ disabled_kinds = [key.removeprefix("notification:") for key, value
1464
+ in self.user_preferences(user_id).items()
1465
+ if key.startswith("notification:") and value == "false"]
1466
+ if disabled_kinds:
1467
+ conditions.append(inbox_notification.c.kind.not_in(disabled_kinds))
1468
+ return conn.execute(select(func.count()).select_from(inbox_notification).where(
1469
+ and_(*conditions))).scalar_one()
1470
+
1471
+ def update_profile_name(self, org_id: str, user_id: str, name: str) -> dict | None:
1472
+ clean_name = name.strip()
1473
+ if not clean_name or len(clean_name) > 80:
1474
+ return None
1475
+ with self.engine.begin() as conn:
1476
+ result = conn.execute(user.update().where(and_(
1477
+ user.c.id == user_id, user.c.organization_id == org_id,
1478
+ user.c.active.is_(True), user.c.archived_at.is_(None),
1479
+ )).values(name=clean_name, display_name=clean_name,
1480
+ updated_at=datetime.now(timezone.utc).isoformat()))
1481
+ if not result.rowcount:
1482
+ return None
1483
+ return self.ser_user(self.get_user(org_id, user_id), user_id)
1484
+
1485
+ def set_notification_preference(self, user_id: str, kind: str, enabled: bool) -> bool:
1486
+ if kind not in {"issueCreated", "issueUpdated"}:
1487
+ return False
1488
+ return self.set_user_preference(user_id, f"notification:{kind}",
1489
+ "true" if enabled else "false")
1490
+
1491
+ def inbox_mark(self, org_id: str, user_id: str, notification_id: str, column: str) -> bool:
1492
+ if column not in ("read_at", "archived_at"):
1493
+ raise ValueError("Unsupported notification state.")
1494
+ from hoja_server.db import now_iso
1495
+ target = inbox_notification.c[column]
1496
+ with self.engine.begin() as conn:
1497
+ result = conn.execute(inbox_notification.update().where(and_(
1498
+ inbox_notification.c.id == notification_id,
1499
+ inbox_notification.c.organization_id == org_id,
1500
+ inbox_notification.c.recipient_id == user_id,
1501
+ target.is_(None),
1502
+ )).values({column: now_iso()}))
1503
+ return result.rowcount > 0 or conn.execute(select(inbox_notification.c.id).where(and_(
1504
+ inbox_notification.c.id == notification_id,
1505
+ inbox_notification.c.organization_id == org_id,
1506
+ inbox_notification.c.recipient_id == user_id,
1507
+ ))).first() is not None
1508
+
1509
+ def inbox_mark_all_read(self, org_id: str, user_id: str) -> bool:
1510
+ from hoja_server.db import now_iso
1511
+ with self.engine.begin() as conn:
1512
+ conn.execute(inbox_notification.update().where(and_(
1513
+ inbox_notification.c.organization_id == org_id,
1514
+ inbox_notification.c.recipient_id == user_id,
1515
+ inbox_notification.c.read_at.is_(None),
1516
+ )).values(read_at=now_iso()))
1517
+ return True
1518
+
1519
+ def my_issue_activity(self, org_id: str, viewer_id: str, after=None, limit=51) -> list[dict]:
1520
+ with self.engine.connect() as conn:
1521
+ stmt = select(issue_activity).join(issue, and_(
1522
+ issue_activity.c.issue_id == issue.c.id,
1523
+ issue_activity.c.organization_id == issue.c.organization_id,
1524
+ )).where(and_(
1525
+ issue_activity.c.organization_id == org_id,
1526
+ issue.c.organization_id == org_id,
1527
+ issue.c.archived_at.is_(None),
1528
+ or_(issue.c.assignee_id == viewer_id, issue.c.creator_id == viewer_id,
1529
+ issue_activity.c.actor_id == viewer_id),
1530
+ ))
1531
+ # Activity carries `changes`, which can repeat a title or body: scope it like the issue itself.
1532
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1533
+ if after:
1534
+ ts, event_id = after
1535
+ stmt = stmt.where(issue_activity.c.created_at <= ts).where(or_(issue_activity.c.created_at < ts,
1536
+ and_(issue_activity.c.created_at == ts, issue_activity.c.id < event_id)))
1537
+ rows = _conn_rows(conn, stmt.order_by(
1538
+ issue_activity.c.created_at.desc(), issue_activity.c.id.desc()).limit(limit))
1539
+ for row in rows:
1540
+ row["changes"] = json.loads(row["changes"]) if row.get("changes") else None
1541
+ return rows
1542
+
1543
+ def project_issue_activity(self, org_id: str, project_id: str, viewer_id: str | None = None,
1544
+ after=None, limit=51, event_types: list[str] | None = None) -> list[dict]:
1545
+ """Events on the issues currently in a project, newest first, limited to teams the viewer can see."""
1546
+ with self.engine.connect() as conn:
1547
+ stmt = select(issue_activity).join(issue, and_(
1548
+ issue_activity.c.issue_id == issue.c.id,
1549
+ issue_activity.c.organization_id == issue.c.organization_id,
1550
+ )).where(and_(
1551
+ issue_activity.c.organization_id == org_id,
1552
+ issue.c.organization_id == org_id,
1553
+ issue.c.project_id == project_id,
1554
+ ))
1555
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1556
+ if event_types:
1557
+ stmt = stmt.where(issue_activity.c.event_type.in_(event_types))
1558
+ if after:
1559
+ ts, event_id = after
1560
+ stmt = stmt.where(issue_activity.c.created_at <= ts).where(or_(issue_activity.c.created_at < ts,
1561
+ and_(issue_activity.c.created_at == ts, issue_activity.c.id < event_id)))
1562
+ rows = _conn_rows(conn, stmt.order_by(
1563
+ issue_activity.c.created_at.desc(), issue_activity.c.id.desc()).limit(limit))
1564
+ for row in rows:
1565
+ row["changes"] = json.loads(row["changes"]) if row.get("changes") else None
1566
+ return rows
1567
+
1568
+ def issue_activity_for(self, org_id: str, issue_id: str, after=None, limit=51) -> list[dict]:
1569
+ with self.engine.connect() as conn:
1570
+ stmt = select(issue_activity).where(and_(
1571
+ issue_activity.c.organization_id == org_id,
1572
+ issue_activity.c.issue_id == issue_id,
1573
+ ))
1574
+ if after:
1575
+ ts, event_id = after
1576
+ stmt = stmt.where(issue_activity.c.created_at <= ts).where(or_(issue_activity.c.created_at < ts,
1577
+ and_(issue_activity.c.created_at == ts, issue_activity.c.id < event_id)))
1578
+ rows = _conn_rows(conn, stmt.order_by(
1579
+ issue_activity.c.created_at.desc(), issue_activity.c.id.desc()).limit(limit))
1580
+ for row in rows:
1581
+ row["changes"] = json.loads(row["changes"]) if row.get("changes") else None
1582
+ return rows
1583
+
1584
+ def labels(self, org_id: str, team_id: str | None = None, name: str | None = None,
1585
+ viewer_id: str | None = None) -> list[dict]:
1586
+ """Labels in the org. With a team_id, its own plus the ones it inherits from ancestors."""
1587
+ with self.engine.connect() as conn:
1588
+ stmt = select(issue_label).where(and_(
1589
+ issue_label.c.organization_id == org_id, issue_label.c.archived_at.is_(None)))
1590
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue_label.c.team_id, nullable=True)
1591
+ if team_id:
1592
+ stmt = stmt.where(issue_label.c.team_id.in_(team_ancestor_ids(conn, org_id, team_id)))
1593
+ if name:
1594
+ stmt = stmt.where(issue_label.c.name == name)
1595
+ return [self.ser_label(r) for r in _conn_rows(conn, stmt.order_by(issue_label.c.name))]
1596
+
1597
+ def issue_labels_for(self, org_id: str, issue_id: str) -> list[dict]:
1598
+ with self.engine.connect() as conn:
1599
+ stmt = select(issue_label).select_from(
1600
+ issue_label.join(issue_label_link, issue_label_link.c.label_id == issue_label.c.id)
1601
+ ).where(and_(issue_label_link.c.issue_id == issue_id,
1602
+ issue_label.c.organization_id == org_id))
1603
+ return [self.ser_label(r) for r in _conn_rows(conn, stmt)]
1604
+
1605
+ # ------------------------------------------------------------ batch loaders
1606
+ # One query per list page instead of one per row (GraphQL resolvers prime them).
1607
+ def issue_labels_for_many(self, org_id: str, issue_ids: list[str]) -> dict[str, list[dict]]:
1608
+ out: dict[str, list[dict]] = {i: [] for i in issue_ids}
1609
+ with self.engine.connect() as conn:
1610
+ for start in range(0, len(issue_ids), 500):
1611
+ chunk = issue_ids[start:start + 500]
1612
+ rows = conn.execute(select(issue_label_link.c.issue_id, issue_label).select_from(
1613
+ issue_label.join(issue_label_link, issue_label_link.c.label_id == issue_label.c.id)
1614
+ ).where(and_(issue_label_link.c.issue_id.in_(chunk),
1615
+ issue_label.c.organization_id == org_id))).mappings().all()
1616
+ for r in rows:
1617
+ row = dict(r)
1618
+ out[row.pop("issue_id")].append(self.ser_label(row))
1619
+ return out
1620
+
1621
+ def issue_subscribers_for_many(self, org_id: str, issue_ids: list[str]) -> dict[str, list[dict]]:
1622
+ out: dict[str, list[dict]] = {i: [] for i in issue_ids}
1623
+ with self.engine.connect() as conn:
1624
+ for start in range(0, len(issue_ids), 500):
1625
+ chunk = issue_ids[start:start + 500]
1626
+ rows = conn.execute(select(issue_subscriber.c.issue_id.label("_issue_id"), user).select_from(
1627
+ user.join(issue_subscriber, issue_subscriber.c.user_id == user.c.id)
1628
+ ).where(and_(issue_subscriber.c.issue_id.in_(chunk),
1629
+ user.c.organization_id == org_id))).mappings().all()
1630
+ for r in rows:
1631
+ row = dict(r)
1632
+ out[row.pop("_issue_id")].append(self.ser_user(row))
1633
+ return out
1634
+
1635
+ def users_by_ids(self, org_id: str, user_ids: list[str]) -> dict[str, dict]:
1636
+ out: dict[str, dict] = {}
1637
+ with self.engine.connect() as conn:
1638
+ for start in range(0, len(user_ids), 500):
1639
+ chunk = user_ids[start:start + 500]
1640
+ for row in _conn_rows(conn, select(user).where(and_(
1641
+ user.c.organization_id == org_id, user.c.id.in_(chunk)))):
1642
+ out[row["id"]] = row
1643
+ return out
1644
+
1645
+ def issues_by_ids(self, org_id: str, issue_ids: list[str], viewer_id: str | None = None) -> dict[str, dict]:
1646
+ out: dict[str, dict] = {}
1647
+ with self.engine.connect() as conn:
1648
+ for start in range(0, len(issue_ids), 500):
1649
+ chunk = issue_ids[start:start + 500]
1650
+ stmt = select(issue).where(and_(issue.c.organization_id == org_id, issue.c.id.in_(chunk)))
1651
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1652
+ for row in _conn_rows(conn, stmt):
1653
+ out[row["id"]] = self.ser_issue(row)
1654
+ return out
1655
+
1656
+ def projects_by_ids(self, org_id: str, project_ids: list[str],
1657
+ viewer_id: str | None = None) -> dict[str, dict]:
1658
+ out: dict[str, dict] = {}
1659
+ with self.engine.connect() as conn:
1660
+ for start in range(0, len(project_ids), 500):
1661
+ chunk = project_ids[start:start + 500]
1662
+ stmt = select(project).where(and_(
1663
+ project.c.organization_id == org_id, project.c.id.in_(chunk),
1664
+ project.c.archived_at.is_(None)))
1665
+ stmt = _scope_projects(conn, stmt, org_id, viewer_id)
1666
+ for row in _conn_rows(conn, stmt):
1667
+ out[row["id"]] = self.ser_project(row)
1668
+ return out
1669
+
1670
+ def project_progress_many(self, org_id: str, project_ids: list[str]) -> dict[str, float]:
1671
+ """Percent (0-100) of each project's non-canceled, non-archived issues that are completed.
1672
+
1673
+ Derived from issues on every read (one grouped query per batch) because the stored
1674
+ project.progress column is never updated after creation."""
1675
+ out: dict[str, float] = {p: 0.0 for p in project_ids}
1676
+ with self.engine.connect() as conn:
1677
+ for start in range(0, len(project_ids), 500):
1678
+ chunk = project_ids[start:start + 500]
1679
+ stmt = select(issue.c.project_id, workflow_state.c.type, func.count()).select_from(
1680
+ issue.join(workflow_state, workflow_state.c.id == issue.c.state_id)
1681
+ ).where(and_(issue.c.organization_id == org_id, issue.c.project_id.in_(chunk),
1682
+ issue.c.archived_at.is_(None))).group_by(issue.c.project_id, workflow_state.c.type)
1683
+ done: dict[str, int] = {}
1684
+ scope: dict[str, int] = {}
1685
+ for pid, kind, n in conn.execute(stmt):
1686
+ if kind == "canceled":
1687
+ continue
1688
+ scope[pid] = scope.get(pid, 0) + n
1689
+ if kind == "completed":
1690
+ done[pid] = done.get(pid, 0) + n
1691
+ for pid, total in scope.items():
1692
+ out[pid] = round(done.get(pid, 0) * 100.0 / total, 2) if total else 0.0
1693
+ return out
1694
+
1695
+ def project_teams_many(self, org_id: str, project_ids: list[str],
1696
+ viewer_id: str | None = None) -> dict[str, list[dict]]:
1697
+ out: dict[str, list[dict]] = {p: [] for p in project_ids}
1698
+ with self.engine.connect() as conn:
1699
+ for start in range(0, len(project_ids), 500):
1700
+ chunk = project_ids[start:start + 500]
1701
+ stmt = select(project_team.c.project_id.label("_project_id"), team).select_from(
1702
+ team.join(project_team, project_team.c.team_id == team.c.id)
1703
+ ).where(and_(project_team.c.project_id.in_(chunk), team.c.organization_id == org_id,
1704
+ team.c.archived_at.is_(None)))
1705
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
1706
+ for r in conn.execute(stmt).mappings().all():
1707
+ row = dict(r)
1708
+ out[row.pop("_project_id")].append(self.ser_team(row))
1709
+ return out
1710
+
1711
+ def issue_subscribers_for(self, org_id: str, issue_id: str, limit: int | None = None) -> list[dict]:
1712
+ with self.engine.connect() as conn:
1713
+ stmt = select(user).select_from(
1714
+ user.join(issue_subscriber, issue_subscriber.c.user_id == user.c.id)
1715
+ ).where(and_(issue_subscriber.c.issue_id == issue_id, user.c.organization_id == org_id))
1716
+ if limit:
1717
+ stmt = stmt.limit(limit)
1718
+ return [self.ser_user(r) for r in _conn_rows(conn, stmt)]
1719
+
1720
+ def comments_for(self, org_id: str, issue_id: str, limit: int | None = None) -> list[dict]:
1721
+ with self.engine.connect() as conn:
1722
+ stmt = select(comment).where(and_(
1723
+ comment.c.organization_id == org_id, comment.c.issue_id == issue_id,
1724
+ comment.c.archived_at.is_(None))).order_by(comment.c.created_at)
1725
+ if limit:
1726
+ stmt = stmt.limit(limit)
1727
+ return [self.ser_comment(r) for r in _conn_rows(conn, stmt)]
1728
+
1729
+ def issue_attachments_for(self, org_id: str, issue_id: str, limit: int | None = None) -> list[dict]:
1730
+ with self.engine.connect() as conn:
1731
+ stmt = select(attachment).where(and_(
1732
+ attachment.c.organization_id == org_id,
1733
+ attachment.c.issue_id == issue_id)).order_by(attachment.c.created_at)
1734
+ if limit:
1735
+ stmt = stmt.limit(limit)
1736
+ return [self.ser_attachment(r) for r in _conn_rows(conn, stmt)]
1737
+
1738
+ def upload_visible(self, org_id: str, viewer_id: str, file_id: str) -> bool:
1739
+ """May this viewer fetch an uploaded file?
1740
+
1741
+ Uploads are org-wide blobs, so the file itself carries no team. When any
1742
+ attachment row points at it, the file takes the visibility of the *most*
1743
+ permissive of those issues; a file no attachment references (an avatar, an
1744
+ in-flight upload) stays readable by any member of the organization.
1745
+ """
1746
+ with self.engine.connect() as conn:
1747
+ issue_ids = list(conn.execute(select(attachment.c.issue_id).where(and_(
1748
+ attachment.c.organization_id == org_id,
1749
+ attachment.c.url.contains(file_id),
1750
+ attachment.c.issue_id.is_not(None)))).scalars())
1751
+ if not issue_ids:
1752
+ return True
1753
+ stmt = select(issue.c.id).where(and_(
1754
+ issue.c.organization_id == org_id, issue.c.id.in_(issue_ids)))
1755
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1756
+ return conn.execute(stmt).first() is not None
1757
+
1758
+ def projects(self, org_id: str, flt: dict | None = None,
1759
+ after: tuple[str, str] | None = None, limit: int | None = None,
1760
+ viewer_id: str | None = None) -> list[dict]:
1761
+ with self.engine.connect() as conn:
1762
+ stmt = select(project).where(and_(
1763
+ project.c.organization_id == org_id, project.c.archived_at.is_(None)))
1764
+ stmt = _apply_project_filter(stmt, flt, conn, org_id)
1765
+ stmt = _scope_projects(conn, stmt, org_id, viewer_id)
1766
+ if after:
1767
+ stmt = stmt.where(project.c.created_at >= after[0]).where((project.c.created_at > after[0]) |
1768
+ ((project.c.created_at == after[0]) & (project.c.id > after[1])))
1769
+ stmt = stmt.order_by(project.c.created_at, project.c.id)
1770
+ if limit is not None:
1771
+ stmt = stmt.limit(limit)
1772
+ return [self.ser_project(r) for r in _conn_rows(conn, stmt)]
1773
+
1774
+ def cycle_by_id(self, org_id: str, cycle_id: str, viewer_id: str | None = None) -> dict | None:
1775
+ with self.engine.connect() as conn:
1776
+ stmt = select(cycle).where(and_(
1777
+ cycle.c.organization_id == org_id, cycle.c.id == cycle_id))
1778
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, cycle.c.team_id)
1779
+ return self.ser_cycle(_one(conn, stmt))
1780
+
1781
+ def project_by_id(self, org_id: str, pid: str, viewer_id: str | None = None) -> dict | None:
1782
+ with self.engine.connect() as conn:
1783
+ stmt = select(project).where(and_(
1784
+ project.c.organization_id == org_id, project.c.id == pid,
1785
+ project.c.archived_at.is_(None)))
1786
+ stmt = _scope_projects(conn, stmt, org_id, viewer_id)
1787
+ return self.ser_project(_one(conn, stmt))
1788
+
1789
+ def initiatives(self, org_id: str, after: tuple[str, str] | None = None, status: str | None = None, limit: int = 51) -> list[dict]:
1790
+ stmt = select(initiative).where(and_(initiative.c.organization_id == org_id, initiative.c.archived_at.is_(None)))
1791
+ if status:
1792
+ stmt = stmt.where(initiative.c.status == status)
1793
+ if after:
1794
+ stmt = stmt.where(initiative.c.created_at <= after[0]).where((initiative.c.created_at < after[0]) | ((initiative.c.created_at == after[0]) & (initiative.c.id < after[1])))
1795
+ with self.engine.connect() as conn:
1796
+ rows = _conn_rows(conn, stmt.order_by(initiative.c.created_at.desc(), initiative.c.id.desc()).limit(limit))
1797
+ return [self.ser_initiative(conn, row) for row in rows]
1798
+
1799
+ def initiative_by_id(self, org_id: str, ident: str) -> dict | None:
1800
+ with self.engine.connect() as conn:
1801
+ row = _one(conn, select(initiative).where(and_(initiative.c.organization_id == org_id, initiative.c.id == ident, initiative.c.archived_at.is_(None))))
1802
+ return self.ser_initiative(conn, row) if row else None
1803
+
1804
+ @staticmethod
1805
+ def ser_initiative(conn, row) -> dict:
1806
+ result = {"id": row["id"], "name": row["name"], "summary": row["summary"], "status": row["status"], "priority": row["priority"], "targetDate": row["target_date"], "createdAt": row["created_at"], "updatedAt": row["updated_at"], "_owner_id": row["owner_id"], "_creator_id": row["creator_id"], "_org_id": row["organization_id"], "owner": None, "creator": None}
1807
+ for field in ("owner", "creator"):
1808
+ uid = row[f"{field}_id"]
1809
+ if uid:
1810
+ user_row = _one(conn, select(user).where(and_(user.c.organization_id == row["organization_id"], user.c.id == uid)))
1811
+ result[field] = Store.__new__(Store).ser_user(user_row) if user_row else None
1812
+ ids = list(conn.execute(select(project.c.id).select_from(project.join(initiative_project, initiative_project.c.project_id == project.c.id)).where(and_(initiative_project.c.organization_id == row["organization_id"], initiative_project.c.initiative_id == row["id"], project.c.organization_id == row["organization_id"], project.c.archived_at.is_(None)))).scalars())
1813
+ result["projects"] = {"nodes": [Store.__new__(Store).ser_project(_one(conn, select(project).where(project.c.id == pid))) for pid in ids], "pageInfo": {"hasNextPage": False, "hasPreviousPage": False, "startCursor": None, "endCursor": None}}
1814
+ return result
1815
+
1816
+ def project_updates(self, org_id: str, after: tuple[str, str] | None = None,
1817
+ project_id: str | None = None, limit: int = 51) -> list[dict]:
1818
+ """Return organization-scoped update posts newest-first, with stable id tie-breaks."""
1819
+ stmt = select(project_update).where(project_update.c.organization_id == org_id)
1820
+ stmt = stmt.join(project, and_(project.c.id == project_update.c.project_id,
1821
+ project.c.organization_id == org_id,
1822
+ project.c.archived_at.is_(None)))
1823
+ if project_id:
1824
+ stmt = stmt.where(project_update.c.project_id == project_id)
1825
+ if after:
1826
+ created_at, update_id = after
1827
+ stmt = stmt.where(project_update.c.created_at <= created_at).where(or_(project_update.c.created_at < created_at,
1828
+ and_(project_update.c.created_at == created_at,
1829
+ project_update.c.id < update_id)))
1830
+ stmt = stmt.order_by(project_update.c.created_at.desc(), project_update.c.id.desc()).limit(limit)
1831
+ with self.engine.connect() as conn:
1832
+ rows = _conn_rows(conn, stmt)
1833
+ result = []
1834
+ for row in rows:
1835
+ row["_project_id"] = row["project_id"]
1836
+ row["_actor_id"] = row.get("actor_id")
1837
+ row["createdAt"] = row["created_at"]
1838
+ result.append(row)
1839
+ return result
1840
+
1841
+ def project_update_project(self, org_id: str, pid: str) -> dict | None:
1842
+ return self.project_by_id(org_id, pid)
1843
+
1844
+ def project_members(self, org_id: str, project_id: str) -> list[dict]:
1845
+ with self.engine.connect() as conn:
1846
+ stmt = select(user).select_from(
1847
+ user.join(project_member, project_member.c.user_id == user.c.id)
1848
+ ).where(and_(project_member.c.project_id == project_id, user.c.organization_id == org_id))
1849
+ return [self.ser_user(r) for r in _conn_rows(conn, stmt)]
1850
+
1851
+ def project_teams(self, org_id: str, project_id: str, viewer_id: str | None = None) -> list[dict]:
1852
+ with self.engine.connect() as conn:
1853
+ stmt = select(team).select_from(
1854
+ team.join(project_team, project_team.c.team_id == team.c.id)
1855
+ ).where(and_(
1856
+ project_team.c.project_id == project_id,
1857
+ team.c.organization_id == org_id,
1858
+ team.c.archived_at.is_(None),
1859
+ ))
1860
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
1861
+ return [self.ser_team(r) for r in _conn_rows(conn, stmt)]
1862
+
1863
+ def state_by_id(self, org_id: str, sid: str) -> dict | None:
1864
+ with self.engine.connect() as conn:
1865
+ return self.ser_state(_one(conn, select(workflow_state).where(and_(
1866
+ workflow_state.c.organization_id == org_id, workflow_state.c.id == sid))))
1867
+
1868
+ def raw_team(self, org_id: str, tid: str, viewer_id: str | None = None) -> dict | None:
1869
+ with self.engine.connect() as conn:
1870
+ stmt = select(team).where(and_(team.c.organization_id == org_id, team.c.id == tid))
1871
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
1872
+ return _one(conn, stmt)
1873
+
1874
+ # --------------------------------------------------------------- issue list
1875
+ def issues(self, org_id: str, viewer_id: str, flt: dict | None,
1876
+ order_by: str = "updatedAt", after: tuple[str, str] | None = None,
1877
+ limit: int | None = None) -> list[dict]:
1878
+ with self.engine.connect() as conn:
1879
+ stmt = select(issue).where(and_(
1880
+ issue.c.organization_id == org_id, issue.c.archived_at.is_(None)))
1881
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1882
+ stmt = _apply_issue_filter(conn, stmt, org_id, viewer_id, flt)
1883
+ col = issue.c.updated_at if order_by == "updatedAt" else issue.c.created_at
1884
+ stmt = stmt.order_by(col.desc(), issue.c.id.desc())
1885
+ if after:
1886
+ from sqlalchemy import or_
1887
+ stmt = stmt.where(col <= after[0]).where(or_(col < after[0], and_(col == after[0], issue.c.id < after[1])))
1888
+ if limit is not None:
1889
+ stmt = stmt.limit(limit)
1890
+ return [self.ser_issue(r) for r in _conn_rows(conn, stmt)]
1891
+
1892
+ def search_issues(self, org_id: str, term: str, viewer_id: str | None = None) -> list[dict]:
1893
+ needle = _like_contains(_check_search_term(term))
1894
+ with self.engine.connect() as conn:
1895
+ stmt = select(issue).where(and_(
1896
+ issue.c.organization_id == org_id, issue.c.archived_at.is_(None),
1897
+ or_(issue.c.title.ilike(needle, escape="\\"),
1898
+ issue.c.description.ilike(needle, escape="\\"),
1899
+ issue.c.identifier.ilike(needle, escape="\\"))))
1900
+ stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
1901
+ stmt = stmt.order_by(issue.c.updated_at.desc())
1902
+ return [self.ser_issue(r) for r in _conn_rows(conn, stmt)]
1903
+
1904
+
1905
+ # ============================================================ filter compilers
1906
+ def _apply_str_cmp(col, cmp: dict):
1907
+ clauses = []
1908
+ if "eq" in cmp:
1909
+ clauses.append(col == cmp["eq"])
1910
+ if "neq" in cmp:
1911
+ clauses.append(col != cmp["neq"])
1912
+ if "in" in cmp:
1913
+ clauses.append(col.in_(cmp["in"]))
1914
+ if "nin" in cmp:
1915
+ clauses.append(col.notin_(cmp["nin"]))
1916
+ if "contains" in cmp:
1917
+ clauses.append(col.ilike(_like_contains(_check_search_term(cmp["contains"])), escape="\\"))
1918
+ if "containsIgnoreCase" in cmp:
1919
+ clauses.append(col.ilike(_like_contains(_check_search_term(cmp["containsIgnoreCase"])), escape="\\"))
1920
+ if "eqIgnoreCase" in cmp:
1921
+ clauses.append(func.lower(col) == _check_search_term(cmp["eqIgnoreCase"]).lower())
1922
+ return and_(*clauses) if clauses else None
1923
+
1924
+
1925
+ def _apply_num_cmp(col, cmp: dict):
1926
+ clauses = []
1927
+ if "eq" in cmp:
1928
+ clauses.append(col == cmp["eq"])
1929
+ if "neq" in cmp:
1930
+ clauses.append(col != cmp["neq"])
1931
+ if "gt" in cmp:
1932
+ clauses.append(col > cmp["gt"])
1933
+ if "lt" in cmp:
1934
+ clauses.append(col < cmp["lt"])
1935
+ if "gte" in cmp:
1936
+ clauses.append(col >= cmp["gte"])
1937
+ if "lte" in cmp:
1938
+ clauses.append(col <= cmp["lte"])
1939
+ if "in" in cmp:
1940
+ clauses.append(col.in_(cmp["in"]))
1941
+ if "nin" in cmp:
1942
+ clauses.append(col.notin_(cmp["nin"]))
1943
+ return and_(*clauses) if clauses else None
1944
+
1945
+
1946
+ def _apply_date_cmp(col, cmp: dict):
1947
+ # ISO strings sort lexicographically → string comparison is correct
1948
+ clauses = []
1949
+ if "eq" in cmp:
1950
+ clauses.append(col == cmp["eq"])
1951
+ if "gt" in cmp:
1952
+ clauses.append(col > cmp["gt"])
1953
+ if "lt" in cmp:
1954
+ clauses.append(col < cmp["lt"])
1955
+ if "gte" in cmp:
1956
+ clauses.append(col >= cmp["gte"])
1957
+ if "lte" in cmp:
1958
+ clauses.append(col <= cmp["lte"])
1959
+ return and_(*clauses) if clauses else None
1960
+
1961
+
1962
+ def _apply_project_filter(stmt, flt: dict | None, conn, org_id: str):
1963
+ if not flt:
1964
+ return stmt
1965
+ if "slugId" in flt:
1966
+ c = _apply_str_cmp(project.c.slug_id, flt["slugId"])
1967
+ if c is not None:
1968
+ stmt = stmt.where(c)
1969
+ if "name" in flt:
1970
+ c = _apply_str_cmp(project.c.name, flt["name"])
1971
+ if c is not None:
1972
+ stmt = stmt.where(c)
1973
+ if "state" in flt:
1974
+ c = _apply_str_cmp(project.c.state, flt["state"])
1975
+ if c is not None:
1976
+ stmt = stmt.where(c)
1977
+ if "team" in flt:
1978
+ team_ids = _resolve_team_ids(conn, org_id, flt["team"])
1979
+ project_ids = select(project_team.c.project_id).where(
1980
+ project_team.c.team_id.in_(team_ids or ["__none__"]))
1981
+ stmt = stmt.where(project.c.id.in_(project_ids))
1982
+ return stmt
1983
+
1984
+
1985
+ def _resolve_team_ids(conn, org_id: str, cmp: dict) -> list[str]:
1986
+ stmt = select(team.c.id).where(team.c.organization_id == org_id)
1987
+ if "key" in cmp:
1988
+ kc = _apply_str_cmp(team.c.key, cmp["key"])
1989
+ if kc is not None:
1990
+ stmt = stmt.where(kc)
1991
+ if "id" in cmp:
1992
+ ic = _apply_str_cmp(team.c.id, cmp["id"])
1993
+ if ic is not None:
1994
+ stmt = stmt.where(ic)
1995
+ if "name" in cmp:
1996
+ nc = _apply_str_cmp(team.c.name, cmp["name"])
1997
+ if nc is not None:
1998
+ stmt = stmt.where(nc)
1999
+ return [r[0] for r in conn.execute(stmt)]
2000
+
2001
+
2002
+ def _resolve_state_ids(conn, org_id: str, cmp: dict) -> list[str]:
2003
+ stmt = select(workflow_state.c.id).where(workflow_state.c.organization_id == org_id)
2004
+ if "name" in cmp:
2005
+ c = _apply_str_cmp(workflow_state.c.name, cmp["name"])
2006
+ if c is not None:
2007
+ stmt = stmt.where(c)
2008
+ if "type" in cmp:
2009
+ c = _apply_str_cmp(workflow_state.c.type, cmp["type"])
2010
+ if c is not None:
2011
+ stmt = stmt.where(c)
2012
+ if "team" in cmp:
2013
+ team_ids = _resolve_team_ids(conn, org_id, cmp["team"])
2014
+ stmt = stmt.where(workflow_state.c.team_id.in_(team_ids or ["__none__"]))
2015
+ return [r[0] for r in conn.execute(stmt)]
2016
+
2017
+
2018
+ def _resolve_user_ids(conn, org_id: str, cmp: dict) -> list[str]:
2019
+ stmt = select(user.c.id).where(user.c.organization_id == org_id)
2020
+ if "id" in cmp:
2021
+ c = _apply_str_cmp(user.c.id, cmp["id"])
2022
+ if c is not None:
2023
+ stmt = stmt.where(c)
2024
+ if "email" in cmp:
2025
+ c = _apply_str_cmp(user.c.email, cmp["email"])
2026
+ if c is not None:
2027
+ stmt = stmt.where(c)
2028
+ if "displayName" in cmp:
2029
+ c = _apply_str_cmp(user.c.display_name, cmp["displayName"])
2030
+ if c is not None:
2031
+ stmt = stmt.where(c)
2032
+ return [r[0] for r in conn.execute(stmt)]
2033
+
2034
+
2035
+ def _resolve_cycle_ids(conn, org_id: str, cmp: dict) -> list[str] | None:
2036
+ """Resolve active/next cycle booleans; return None when neither is requested."""
2037
+ requested = {"isActive", "isNext"}.intersection(cmp)
2038
+ if not requested:
2039
+ return None
2040
+ all_ids = set(
2041
+ conn.execute(
2042
+ select(cycle.c.id).where(cycle.c.organization_id == org_id)
2043
+ ).scalars()
2044
+ )
2045
+ matches: set[str] | None = None
2046
+ team_rows = conn.execute(
2047
+ select(team.c.id, team.c.active_cycle_id).where(
2048
+ team.c.organization_id == org_id
2049
+ )
2050
+ ).all()
2051
+ for field in requested:
2052
+ positive: set[str] = set()
2053
+ if field == "isActive":
2054
+ positive = {row[1] for row in team_rows if row[1]}
2055
+ else:
2056
+ for team_id, active_cycle_id in team_rows:
2057
+ rows = conn.execute(
2058
+ select(cycle.c.id)
2059
+ .where(
2060
+ and_(
2061
+ cycle.c.organization_id == org_id,
2062
+ cycle.c.team_id == team_id,
2063
+ )
2064
+ )
2065
+ .order_by(cycle.c.number)
2066
+ ).scalars().all()
2067
+ if not rows:
2068
+ continue
2069
+ if active_cycle_id in rows:
2070
+ index = rows.index(active_cycle_id) + 1
2071
+ if index < len(rows):
2072
+ positive.add(rows[index])
2073
+ elif not active_cycle_id:
2074
+ positive.add(rows[0])
2075
+ comparator = cmp[field] or {}
2076
+ if "eq" in comparator:
2077
+ expected = bool(comparator["eq"])
2078
+ elif "neq" in comparator:
2079
+ expected = not bool(comparator["neq"])
2080
+ else:
2081
+ continue
2082
+ field_matches = positive if expected else all_ids - positive
2083
+ matches = field_matches if matches is None else matches.intersection(field_matches)
2084
+ return sorted(matches or [])
2085
+
2086
+
2087
+ def _apply_issue_filter(conn, stmt, org_id, viewer_id, flt: dict | None):
2088
+ if not flt:
2089
+ return stmt
2090
+ if "team" in flt:
2091
+ ids = _resolve_team_ids(conn, org_id, flt["team"])
2092
+ stmt = stmt.where(issue.c.team_id.in_(ids or ["__none__"]))
2093
+ if "state" in flt:
2094
+ ids = _resolve_state_ids(conn, org_id, flt["state"])
2095
+ stmt = stmt.where(issue.c.state_id.in_(ids or ["__none__"]))
2096
+ for who, col in (("assignee", issue.c.assignee_id), ("creator", issue.c.creator_id)):
2097
+ if who in flt:
2098
+ ids = _resolve_user_ids(conn, org_id, flt[who])
2099
+ stmt = stmt.where(col.in_(ids or ["__none__"]))
2100
+ if "project" in flt:
2101
+ cmp = flt["project"]
2102
+ if "id" in cmp:
2103
+ c = _apply_str_cmp(issue.c.project_id, cmp["id"])
2104
+ if c is not None:
2105
+ stmt = stmt.where(c)
2106
+ elif "name" in cmp:
2107
+ sub = select(project.c.id).where(project.c.organization_id == org_id)
2108
+ nc = _apply_str_cmp(project.c.name, cmp["name"])
2109
+ if nc is not None:
2110
+ sub = sub.where(nc)
2111
+ stmt = stmt.where(issue.c.project_id.in_([r[0] for r in conn.execute(sub)] or ["__none__"]))
2112
+ if "cycle" in flt:
2113
+ cycle_filter = flt["cycle"]
2114
+ sub = select(cycle.c.id).where(cycle.c.organization_id == org_id)
2115
+ for field, column in (("id", cycle.c.id), ("name", cycle.c.name)):
2116
+ if field in cycle_filter:
2117
+ clause = _apply_str_cmp(column, cycle_filter[field])
2118
+ if clause is not None:
2119
+ sub = sub.where(clause)
2120
+ cycle_ids = {row[0] for row in conn.execute(sub)}
2121
+ boolean_ids = _resolve_cycle_ids(conn, org_id, cycle_filter)
2122
+ if boolean_ids is not None:
2123
+ cycle_ids.intersection_update(boolean_ids)
2124
+ stmt = stmt.where(issue.c.cycle_id.in_(cycle_ids or ["__none__"]))
2125
+ if "parent" in flt:
2126
+ cmp = flt["parent"] or {}
2127
+ if "id" in cmp:
2128
+ c = _apply_str_cmp(issue.c.parent_id, cmp["id"])
2129
+ if c is not None:
2130
+ stmt = stmt.where(c)
2131
+ if "priority" in flt:
2132
+ c = _apply_num_cmp(issue.c.priority, flt["priority"])
2133
+ if c is not None:
2134
+ stmt = stmt.where(c)
2135
+ if "number" in flt:
2136
+ c = _apply_num_cmp(issue.c.number, flt["number"])
2137
+ if c is not None:
2138
+ stmt = stmt.where(c)
2139
+ if "identifier" in flt:
2140
+ c = _apply_str_cmp(issue.c.identifier, flt["identifier"])
2141
+ if c is not None:
2142
+ stmt = stmt.where(c)
2143
+ if "title" in flt:
2144
+ c = _apply_str_cmp(issue.c.title, flt["title"])
2145
+ if c is not None:
2146
+ stmt = stmt.where(c)
2147
+ if "description" in flt:
2148
+ c = _apply_str_cmp(issue.c.description, flt["description"])
2149
+ if c is not None:
2150
+ stmt = stmt.where(c)
2151
+ for datef, col in (("updatedAt", issue.c.updated_at), ("createdAt", issue.c.created_at),
2152
+ ("dueDate", issue.c.due_date)):
2153
+ if datef in flt:
2154
+ c = _apply_date_cmp(col, flt[datef])
2155
+ if c is not None:
2156
+ stmt = stmt.where(c)
2157
+ if "labels" in flt and "name" in flt["labels"]:
2158
+ sub = select(issue_label_link.c.issue_id).select_from(
2159
+ issue_label_link.join(issue_label, issue_label.c.id == issue_label_link.c.label_id)
2160
+ ).where(issue_label.c.organization_id == org_id)
2161
+ nc = _apply_str_cmp(issue_label.c.name, flt["labels"]["name"])
2162
+ if nc is not None:
2163
+ sub = sub.where(nc)
2164
+ stmt = stmt.where(issue.c.id.in_([r[0] for r in conn.execute(sub)] or ["__none__"]))
2165
+ for sub_filter in flt.get("and") or []:
2166
+ stmt = _apply_issue_filter(conn, stmt, org_id, viewer_id, sub_filter)
2167
+ if flt.get("or"):
2168
+ alternatives = []
2169
+ for sub_filter in flt["or"]:
2170
+ subquery = select(issue.c.id).where(and_(
2171
+ issue.c.organization_id == org_id,
2172
+ issue.c.archived_at.is_(None),
2173
+ ))
2174
+ subquery = _apply_issue_filter(
2175
+ conn, subquery, org_id, viewer_id, sub_filter
2176
+ )
2177
+ alternatives.append(issue.c.id.in_(subquery))
2178
+ stmt = stmt.where(or_(*alternatives))
2179
+ return stmt