@imenam/mcp-organizer 0.0.0-stage → 1.0.2

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 (165) hide show
  1. package/README.md +548 -3
  2. package/dist/shared/logic/first-account.d.ts +13 -0
  3. package/dist/shared/logic/first-account.js +15 -0
  4. package/dist/shared/logic/labels.d.ts +5 -0
  5. package/dist/shared/logic/labels.js +17 -0
  6. package/dist/shared/logic/positions.d.ts +9 -0
  7. package/dist/shared/logic/positions.js +19 -0
  8. package/dist/shared/logic/spaces.d.ts +13 -0
  9. package/dist/shared/logic/spaces.js +48 -0
  10. package/dist/shared/logic/test-summary.d.ts +28 -0
  11. package/dist/shared/logic/test-summary.js +35 -0
  12. package/dist/shared/schemas/agent-api.schema.d.ts +269 -0
  13. package/dist/shared/schemas/agent-api.schema.js +97 -0
  14. package/dist/shared/schemas/agent-tools.schema.d.ts +818 -0
  15. package/dist/shared/schemas/agent-tools.schema.js +198 -0
  16. package/dist/shared/schemas/api.schema.d.ts +249 -0
  17. package/dist/shared/schemas/api.schema.js +116 -0
  18. package/dist/shared/schemas/entities.schema.d.ts +472 -0
  19. package/dist/shared/schemas/entities.schema.js +262 -0
  20. package/dist/shared/schemas/enums.schema.d.ts +135 -0
  21. package/dist/shared/schemas/enums.schema.js +58 -0
  22. package/dist/shared/schemas/gui-api.schema.d.ts +53 -0
  23. package/dist/shared/schemas/gui-api.schema.js +20 -0
  24. package/dist/shared/schemas/ideas-api.schema.d.ts +81 -0
  25. package/dist/shared/schemas/ideas-api.schema.js +40 -0
  26. package/dist/shared/schemas/notifications-api.schema.d.ts +87 -0
  27. package/dist/shared/schemas/notifications-api.schema.js +58 -0
  28. package/dist/shared/schemas/tasks-api.schema.d.ts +675 -0
  29. package/dist/shared/schemas/tasks-api.schema.js +225 -0
  30. package/dist/shared/schemas/tokens-api.schema.d.ts +106 -0
  31. package/dist/shared/schemas/tokens-api.schema.js +42 -0
  32. package/dist/shared/schemas/transcription-api.schema.d.ts +15 -0
  33. package/dist/shared/schemas/transcription-api.schema.js +12 -0
  34. package/dist/src/agent-api/checklist.d.ts +3 -0
  35. package/dist/src/agent-api/checklist.js +127 -0
  36. package/dist/src/agent-api/ideas.d.ts +3 -0
  37. package/dist/src/agent-api/ideas.js +93 -0
  38. package/dist/src/agent-api/notifications.d.ts +16 -0
  39. package/dist/src/agent-api/notifications.js +91 -0
  40. package/dist/src/agent-api/requests.d.ts +3 -0
  41. package/dist/src/agent-api/requests.js +60 -0
  42. package/dist/src/agent-api/router.d.ts +3 -0
  43. package/dist/src/agent-api/router.js +41 -0
  44. package/dist/src/agent-api/tasks.d.ts +2 -0
  45. package/dist/src/agent-api/tasks.js +290 -0
  46. package/dist/src/agent-api/types.d.ts +41 -0
  47. package/dist/src/agent-api/types.js +14 -0
  48. package/dist/src/cli/backup.d.ts +12 -0
  49. package/dist/src/cli/backup.js +126 -0
  50. package/dist/src/cli/prompt.d.ts +7 -0
  51. package/dist/src/cli/prompt.js +40 -0
  52. package/dist/src/cli/set-user.d.ts +2 -0
  53. package/dist/src/cli/set-user.js +114 -0
  54. package/dist/src/cli/setup-mcp.d.ts +10 -0
  55. package/dist/src/cli/setup-mcp.js +110 -0
  56. package/dist/src/cli/setup.d.ts +39 -0
  57. package/dist/src/cli/setup.js +626 -0
  58. package/dist/src/cli/watch.d.ts +4 -0
  59. package/dist/src/cli/watch.js +62 -0
  60. package/dist/src/core/accounts.d.ts +129 -0
  61. package/dist/src/core/accounts.js +284 -0
  62. package/dist/src/core/agent-tokens.d.ts +31 -0
  63. package/dist/src/core/agent-tokens.js +120 -0
  64. package/dist/src/core/agent.d.ts +71 -0
  65. package/dist/src/core/agent.js +358 -0
  66. package/dist/src/core/attachments.d.ts +21 -0
  67. package/dist/src/core/attachments.js +44 -0
  68. package/dist/src/core/data-dir.d.ts +5 -0
  69. package/dist/src/core/data-dir.js +11 -0
  70. package/dist/src/core/db.d.ts +13 -0
  71. package/dist/src/core/db.js +38 -0
  72. package/dist/src/core/env-file.d.ts +11 -0
  73. package/dist/src/core/env-file.js +36 -0
  74. package/dist/src/core/errors.d.ts +24 -0
  75. package/dist/src/core/errors.js +42 -0
  76. package/dist/src/core/history.d.ts +34 -0
  77. package/dist/src/core/history.js +279 -0
  78. package/dist/src/core/ideas.d.ts +16 -0
  79. package/dist/src/core/ideas.js +100 -0
  80. package/dist/src/core/ids.d.ts +2 -0
  81. package/dist/src/core/ids.js +5 -0
  82. package/dist/src/core/log-dir.d.ts +2 -0
  83. package/dist/src/core/log-dir.js +9 -0
  84. package/dist/src/core/logger.d.ts +14 -0
  85. package/dist/src/core/logger.js +37 -0
  86. package/dist/src/core/mail.d.ts +43 -0
  87. package/dist/src/core/mail.js +44 -0
  88. package/dist/src/core/mcp-entry.d.ts +20 -0
  89. package/dist/src/core/mcp-entry.js +15 -0
  90. package/dist/src/core/migrations.d.ts +18 -0
  91. package/dist/src/core/migrations.js +404 -0
  92. package/dist/src/core/native-sqlite.d.ts +2 -0
  93. package/dist/src/core/native-sqlite.js +23 -0
  94. package/dist/src/core/notifications.d.ts +46 -0
  95. package/dist/src/core/notifications.js +173 -0
  96. package/dist/src/core/project-root.d.ts +2 -0
  97. package/dist/src/core/project-root.js +13 -0
  98. package/dist/src/core/requests.d.ts +50 -0
  99. package/dist/src/core/requests.js +240 -0
  100. package/dist/src/core/snapshots.d.ts +32 -0
  101. package/dist/src/core/snapshots.js +35 -0
  102. package/dist/src/core/spaces.d.ts +66 -0
  103. package/dist/src/core/spaces.js +252 -0
  104. package/dist/src/core/store.d.ts +577 -0
  105. package/dist/src/core/store.js +2119 -0
  106. package/dist/src/core/task-history.d.ts +7 -0
  107. package/dist/src/core/task-history.js +41 -0
  108. package/dist/src/core/tasks.d.ts +58 -0
  109. package/dist/src/core/tasks.js +389 -0
  110. package/dist/src/core/test-tasks.d.ts +19 -0
  111. package/dist/src/core/test-tasks.js +135 -0
  112. package/dist/src/core/transcription.d.ts +45 -0
  113. package/dist/src/core/transcription.js +92 -0
  114. package/dist/src/core/watchers.d.ts +30 -0
  115. package/dist/src/core/watchers.js +50 -0
  116. package/dist/src/deploy/env-render.d.ts +26 -0
  117. package/dist/src/deploy/env-render.js +98 -0
  118. package/dist/src/deploy/templates.d.ts +38 -0
  119. package/dist/src/deploy/templates.js +130 -0
  120. package/dist/src/gui-api.d.ts +37 -0
  121. package/dist/src/gui-api.js +383 -0
  122. package/dist/src/index.d.ts +2 -0
  123. package/dist/src/index.js +110 -0
  124. package/dist/src/mcp/client.d.ts +38 -0
  125. package/dist/src/mcp/client.js +106 -0
  126. package/dist/src/mcp/notifications.d.ts +13 -0
  127. package/dist/src/mcp/notifications.js +54 -0
  128. package/dist/src/mcp/start.d.ts +14 -0
  129. package/dist/src/mcp/start.js +123 -0
  130. package/dist/src/mcp/tools.d.ts +46 -0
  131. package/dist/src/mcp/tools.js +348 -0
  132. package/dist/src/mcp/watch-handoff.d.ts +35 -0
  133. package/dist/src/mcp/watch-handoff.js +91 -0
  134. package/dist/src/server/app.d.ts +25 -0
  135. package/dist/src/server/app.js +305 -0
  136. package/dist/src/server/config.d.ts +23 -0
  137. package/dist/src/server/config.js +75 -0
  138. package/dist/src/server/limits.d.ts +2 -0
  139. package/dist/src/server/limits.js +4 -0
  140. package/dist/src/server/login-limiter.d.ts +36 -0
  141. package/dist/src/server/login-limiter.js +81 -0
  142. package/dist/src/server/start.d.ts +5 -0
  143. package/dist/src/server/start.js +88 -0
  144. package/dist/src/version.d.ts +1 -0
  145. package/dist/src/version.js +16 -0
  146. package/gui/dist/assets/index-BVN0agCc.css +1 -0
  147. package/gui/dist/assets/index-DI1_kr81.js +70 -0
  148. package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  149. package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  150. package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  151. package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  152. package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  153. package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  154. package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  155. package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  156. package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  157. package/gui/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  158. package/gui/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  159. package/gui/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  160. package/gui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  161. package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  162. package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  163. package/gui/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  164. package/gui/dist/index.html +13 -0
  165. package/package.json +66 -4
@@ -0,0 +1,2119 @@
1
+ import { openDatabase } from "./db.js";
2
+ import { ForbiddenError, InvariantError, NotFoundError, OrganizerError } from "./errors.js";
3
+ import { positionBetween } from "../../shared/logic/positions.js";
4
+ import { newId } from "./ids.js";
5
+ import { WatcherRegistry } from "./watchers.js";
6
+ import { MAX_TASK_DEPTH, } from "../../shared/schemas/enums.schema.js";
7
+ import { AccessLinkSchema, AgentActionSchema, AgentTokenSchema, AgentRequestSchema, AttachmentSchema, ChangeSchema, ChecklistItemSchema, CommentSchema, EntityVersionSchema, IdeaSchema, IdeaSnapshotSchema, InvitationSchema, MembershipSchema, NotificationSchema, SessionSchema, SpaceSchema, TagSchema, TaskSchema, TaskSnapshotSchema, TestCheckpointSchema, TestResultSchema, TestReviewSchema, UserSchema, UserSettingsSchema, } from "../../shared/schemas/entities.schema.js";
8
+ /** Valide `value` avec `schema` ; une donnée invalide est une erreur métier qui liste les champs fautifs. */
9
+ function validate(schema, value, what) {
10
+ const parsed = schema.safeParse(value);
11
+ if (!parsed.success) {
12
+ const issues = parsed.error.issues.map((issue) => `${issue.path.map(String).join(".") || "(root)"} ${issue.message}`);
13
+ throw new OrganizerError(`Invalid ${what}: ${issues.join("; ")}.`);
14
+ }
15
+ return parsed.data;
16
+ }
17
+ function bool(value) {
18
+ return value === 1;
19
+ }
20
+ function userFromRow(row) {
21
+ return UserSchema.parse({
22
+ id: row.id,
23
+ email: row.email,
24
+ displayName: row.display_name,
25
+ passwordHash: row.password_hash,
26
+ status: row.status,
27
+ createdAt: row.created_at,
28
+ });
29
+ }
30
+ function settingsFromRow(row) {
31
+ return UserSettingsSchema.parse({
32
+ userId: row.user_id,
33
+ notifyAssignApp: bool(row.notify_assign_app),
34
+ notifyAssignEmail: bool(row.notify_assign_email),
35
+ notifyCommentApp: bool(row.notify_comment_app),
36
+ notifyCommentEmail: bool(row.notify_comment_email),
37
+ });
38
+ }
39
+ function sessionFromRow(row) {
40
+ return SessionSchema.parse({ idHash: row.id_hash, userId: row.user_id, createdAt: row.created_at, expiresAt: row.expires_at });
41
+ }
42
+ function accessLinkFromRow(row) {
43
+ return AccessLinkSchema.parse({
44
+ id: row.id,
45
+ userId: row.user_id,
46
+ purpose: row.purpose,
47
+ invitationId: row.invitation_id,
48
+ tokenHash: row.token_hash,
49
+ expiresAt: row.expires_at,
50
+ usedAt: row.used_at,
51
+ createdAt: row.created_at,
52
+ });
53
+ }
54
+ function agentTokenFromRow(row) {
55
+ return AgentTokenSchema.parse({
56
+ id: row.id,
57
+ userId: row.user_id,
58
+ spaceId: row.space_id,
59
+ name: row.name,
60
+ prefix: row.prefix,
61
+ tokenHash: row.token_hash,
62
+ createdAt: row.created_at,
63
+ lastUsedAt: row.last_used_at,
64
+ expiresAt: row.expires_at,
65
+ revokedAt: row.revoked_at,
66
+ });
67
+ }
68
+ function invitationFromRow(row) {
69
+ return InvitationSchema.parse({
70
+ id: row.id,
71
+ spaceId: row.space_id,
72
+ email: row.email,
73
+ invitedBy: row.invited_by,
74
+ createdAt: row.created_at,
75
+ expiresAt: row.expires_at,
76
+ acceptedAt: row.accepted_at,
77
+ revokedAt: row.revoked_at,
78
+ declinedAt: row.declined_at,
79
+ });
80
+ }
81
+ function spaceFromRow(row) {
82
+ return SpaceSchema.parse({
83
+ id: row.id,
84
+ name: row.name,
85
+ kind: row.kind,
86
+ ownerId: row.owner_id,
87
+ keyPrefix: row.key_prefix,
88
+ nextTaskNumber: row.next_task_number,
89
+ createdAt: row.created_at,
90
+ });
91
+ }
92
+ function membershipFromRow(row) {
93
+ return MembershipSchema.parse({ spaceId: row.space_id, userId: row.user_id, role: row.role, joinedAt: row.joined_at });
94
+ }
95
+ function taskFromRow(row) {
96
+ return TaskSchema.parse({
97
+ id: row.id,
98
+ spaceId: row.space_id,
99
+ number: row.number,
100
+ parentId: row.parent_id,
101
+ depth: row.depth,
102
+ type: row.type,
103
+ title: row.title,
104
+ description: row.description,
105
+ status: row.status,
106
+ priority: row.priority,
107
+ position: row.position,
108
+ testedTaskId: row.tested_task_id,
109
+ createdBy: row.created_by,
110
+ createdAt: row.created_at,
111
+ updatedAt: row.updated_at,
112
+ version: row.version,
113
+ });
114
+ }
115
+ function ideaFromRow(row) {
116
+ return IdeaSchema.parse({
117
+ id: row.id,
118
+ spaceId: row.space_id,
119
+ authorId: row.author_id,
120
+ text: row.text,
121
+ origin: row.origin,
122
+ requestId: row.request_id,
123
+ promotedTaskId: row.promoted_task_id,
124
+ archivedAt: row.archived_at,
125
+ createdAt: row.created_at,
126
+ });
127
+ }
128
+ function requestFromRow(row) {
129
+ return AgentRequestSchema.parse({
130
+ id: row.id,
131
+ authorId: row.author_id,
132
+ targetSpaceId: row.target_space_id,
133
+ kind: row.kind,
134
+ contextTaskId: row.context_task_id,
135
+ contextIdeaId: row.context_idea_id,
136
+ text: row.text,
137
+ status: row.status,
138
+ reply: row.reply,
139
+ failureReason: row.failure_reason,
140
+ createdAt: row.created_at,
141
+ updatedAt: row.updated_at,
142
+ claimedAt: row.claimed_at,
143
+ claimedByTokenId: row.claimed_by_token_id,
144
+ completedAt: row.completed_at,
145
+ cancelledBy: row.cancelled_by,
146
+ });
147
+ }
148
+ function versionFromRow(row) {
149
+ const raw = row.snapshot === null ? null : JSON.parse(row.snapshot);
150
+ const snapshot = raw === null ? null : row.entity_type === "task" ? TaskSnapshotSchema.parse(raw) : IdeaSnapshotSchema.parse(raw);
151
+ return EntityVersionSchema.parse({
152
+ entityType: row.entity_type,
153
+ entityId: row.entity_id,
154
+ version: row.version,
155
+ snapshot,
156
+ actorType: row.actor_type,
157
+ actorId: row.actor_id,
158
+ requestId: row.request_id,
159
+ tokenId: row.token_id,
160
+ createdAt: row.created_at,
161
+ });
162
+ }
163
+ function changeFromRow(row) {
164
+ return ChangeSchema.parse({
165
+ seq: row.seq,
166
+ spaceId: row.space_id,
167
+ userId: row.user_id,
168
+ entityType: row.entity_type,
169
+ entityId: row.entity_id,
170
+ taskId: row.task_id,
171
+ at: row.at,
172
+ });
173
+ }
174
+ function tagFromRow(row) {
175
+ return TagSchema.parse({ id: row.id, spaceId: row.space_id, name: row.name });
176
+ }
177
+ function checklistItemFromRow(row) {
178
+ return ChecklistItemSchema.parse({ id: row.id, taskId: row.task_id, text: row.text, done: bool(row.done), position: row.position });
179
+ }
180
+ function commentFromRow(row) {
181
+ return CommentSchema.parse({
182
+ id: row.id,
183
+ taskId: row.task_id,
184
+ authorId: row.author_id,
185
+ body: row.body,
186
+ createdAt: row.created_at,
187
+ editedAt: row.edited_at,
188
+ requestId: row.request_id,
189
+ });
190
+ }
191
+ function agentActionFromRow(row) {
192
+ return AgentActionSchema.parse({
193
+ id: row.id,
194
+ requestId: row.request_id,
195
+ seq: row.seq,
196
+ entityType: row.entity_type,
197
+ entityId: row.entity_id,
198
+ operation: row.operation,
199
+ before: row.before === null ? null : JSON.parse(row.before),
200
+ after: row.after === null ? null : JSON.parse(row.after),
201
+ summary: row.summary,
202
+ createdAt: row.created_at,
203
+ undoneAt: row.undone_at,
204
+ undoneBy: row.undone_by,
205
+ tokenId: row.token_id,
206
+ });
207
+ }
208
+ function attachmentFromRow(row) {
209
+ return AttachmentSchema.parse({
210
+ id: row.id,
211
+ taskId: row.task_id,
212
+ uploaderId: row.uploader_id,
213
+ filename: row.filename,
214
+ mime: row.mime,
215
+ size: row.size,
216
+ storagePath: row.storage_path,
217
+ createdAt: row.created_at,
218
+ });
219
+ }
220
+ function checkpointFromRow(row) {
221
+ return TestCheckpointSchema.parse({ id: row.id, taskId: row.task_id, text: row.text, position: row.position });
222
+ }
223
+ function testResultFromRow(row) {
224
+ return TestResultSchema.parse({
225
+ checkpointId: row.checkpoint_id,
226
+ testerId: row.tester_id,
227
+ result: row.result,
228
+ comment: row.comment,
229
+ updatedAt: row.updated_at,
230
+ });
231
+ }
232
+ function testReviewFromRow(row) {
233
+ return TestReviewSchema.parse({
234
+ id: row.id,
235
+ taskId: row.task_id,
236
+ testerId: row.tester_id,
237
+ text: row.text,
238
+ submittedAt: row.submitted_at,
239
+ requestId: row.request_id,
240
+ });
241
+ }
242
+ function notificationFromRow(row) {
243
+ return NotificationSchema.parse({
244
+ id: row.id,
245
+ userId: row.user_id,
246
+ type: row.type,
247
+ taskId: row.task_id,
248
+ actorType: row.actor_type,
249
+ actorId: row.actor_id,
250
+ requestId: row.request_id,
251
+ commentId: row.comment_id,
252
+ inApp: bool(row.in_app),
253
+ createdAt: row.created_at,
254
+ readAt: row.read_at,
255
+ emailedAt: row.emailed_at,
256
+ emailError: row.email_error,
257
+ });
258
+ }
259
+ /** Libellé d'une tâche dans les messages : `ORG-41`. */
260
+ function taskLabel(db, task) {
261
+ return `${keyPrefixOf(db, task.spaceId)}-${task.number}`;
262
+ }
263
+ function keyPrefixOf(db, spaceId) {
264
+ const row = db.prepare("SELECT key_prefix FROM spaces WHERE id = ?").get(spaceId);
265
+ if (!row)
266
+ throw new NotFoundError(`Space ${spaceId} not found.`);
267
+ return row.key_prefix;
268
+ }
269
+ // --- Store ------------------------------------------------------------------
270
+ /**
271
+ * Accès à la base. Les lectures sont libres ; toute écriture passe par
272
+ * `mutate`, qui l'exécute dans une transaction, vérifie les invariants,
273
+ * enregistre les versions des entités versionnées et alimente le journal
274
+ * `Change`.
275
+ */
276
+ export class Store {
277
+ db;
278
+ activeMutation = null;
279
+ emailListener = null;
280
+ pendingRequestListeners = new Set();
281
+ /** Présence des veilleurs de notifications, par jeton d'agent. */
282
+ watchers = new WatcherRegistry();
283
+ constructor(db) {
284
+ this.db = db;
285
+ }
286
+ /** Ouvre (et migre) la base du répertoire de données. */
287
+ static open(dataDir) {
288
+ return new Store(openDatabase(dataDir));
289
+ }
290
+ close() {
291
+ this.db.close();
292
+ }
293
+ // --- Utilisateurs ---
294
+ findUser(id) {
295
+ const row = this.db.prepare("SELECT * FROM users WHERE id = ?").get(id);
296
+ return row ? userFromRow(row) : null;
297
+ }
298
+ getUser(id) {
299
+ const user = this.findUser(id);
300
+ if (!user)
301
+ throw new NotFoundError(`User ${id} not found.`);
302
+ return user;
303
+ }
304
+ findUserByEmail(email) {
305
+ const row = this.db.prepare("SELECT * FROM users WHERE email = ?").get(email.trim().toLowerCase());
306
+ return row ? userFromRow(row) : null;
307
+ }
308
+ countUsers() {
309
+ return this.db.prepare("SELECT COUNT(*) AS n FROM users").get().n;
310
+ }
311
+ getUserSettings(userId) {
312
+ const row = this.db.prepare("SELECT * FROM user_settings WHERE user_id = ?").get(userId);
313
+ if (!row)
314
+ throw new NotFoundError(`Settings of user ${userId} not found.`);
315
+ return settingsFromRow(row);
316
+ }
317
+ // --- Sessions et liens d'accès ---
318
+ findSessionByHash(idHash) {
319
+ const row = this.db.prepare("SELECT * FROM sessions WHERE id_hash = ?").get(idHash);
320
+ return row ? sessionFromRow(row) : null;
321
+ }
322
+ findAccessLinkByHash(tokenHash) {
323
+ const row = this.db.prepare("SELECT * FROM access_links WHERE token_hash = ?").get(tokenHash);
324
+ return row ? accessLinkFromRow(row) : null;
325
+ }
326
+ /** Liens d'accès inutilisés d'un compte. */
327
+ listUnusedAccessLinks(userId) {
328
+ const rows = this.db.prepare("SELECT * FROM access_links WHERE user_id = ? AND used_at IS NULL ORDER BY created_at").all(userId);
329
+ return rows.map(accessLinkFromRow);
330
+ }
331
+ // --- Jetons d'agent ---
332
+ findAgentToken(id) {
333
+ const row = this.db.prepare("SELECT * FROM agent_tokens WHERE id = ?").get(id);
334
+ return row ? agentTokenFromRow(row) : null;
335
+ }
336
+ findAgentTokenByHash(tokenHash) {
337
+ const row = this.db.prepare("SELECT * FROM agent_tokens WHERE token_hash = ?").get(tokenHash);
338
+ return row ? agentTokenFromRow(row) : null;
339
+ }
340
+ /** Jetons qui couvrent un espace, tous propriétaires confondus, du plus récemment utilisé au moins récemment. */
341
+ listAgentTokensOfSpace(spaceId) {
342
+ const rows = this.db
343
+ .prepare("SELECT * FROM agent_tokens WHERE space_id = ? ORDER BY last_used_at IS NULL, last_used_at DESC, created_at DESC, id DESC")
344
+ .all(spaceId);
345
+ return rows.map(agentTokenFromRow);
346
+ }
347
+ /** Jetons d'un utilisateur, du plus récent au plus ancien. */
348
+ listAgentTokensOf(userId) {
349
+ const rows = this.db.prepare("SELECT * FROM agent_tokens WHERE user_id = ? ORDER BY created_at DESC, id DESC").all(userId);
350
+ return rows.map(agentTokenFromRow);
351
+ }
352
+ // --- Invitations ---
353
+ findInvitation(id) {
354
+ const row = this.db.prepare("SELECT * FROM invitations WHERE id = ?").get(id);
355
+ return row ? invitationFromRow(row) : null;
356
+ }
357
+ getInvitation(id) {
358
+ const invitation = this.findInvitation(id);
359
+ if (!invitation)
360
+ throw new NotFoundError(`Invitation ${id} not found.`);
361
+ return invitation;
362
+ }
363
+ /** Invitations en attente (ni acceptées, ni révoquées, ni refusées) d'un espace, par date. */
364
+ listOpenInvitations(spaceId) {
365
+ const rows = this.db
366
+ .prepare("SELECT * FROM invitations WHERE space_id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND declined_at IS NULL ORDER BY created_at, id")
367
+ .all(spaceId);
368
+ return rows.map(invitationFromRow);
369
+ }
370
+ /** Invitations en attente adressées à une adresse, tous espaces confondus, par date. */
371
+ listOpenInvitationsForEmail(email) {
372
+ const rows = this.db
373
+ .prepare("SELECT * FROM invitations WHERE email = ? AND accepted_at IS NULL AND revoked_at IS NULL AND declined_at IS NULL ORDER BY created_at, id")
374
+ .all(email.trim().toLowerCase());
375
+ return rows.map(invitationFromRow);
376
+ }
377
+ // --- Espaces et membres ---
378
+ findSpace(id) {
379
+ const row = this.db.prepare("SELECT * FROM spaces WHERE id = ?").get(id);
380
+ return row ? spaceFromRow(row) : null;
381
+ }
382
+ getSpace(id) {
383
+ const space = this.findSpace(id);
384
+ if (!space)
385
+ throw new NotFoundError(`Space ${id} not found.`);
386
+ return space;
387
+ }
388
+ findPersonalSpace(userId) {
389
+ const row = this.db.prepare("SELECT * FROM spaces WHERE owner_id = ? AND kind = 'personal'").get(userId);
390
+ return row ? spaceFromRow(row) : null;
391
+ }
392
+ /** Espaces dont l'utilisateur est membre : son espace personnel d'abord, puis les espaces communs par nom. */
393
+ listSpacesOf(userId) {
394
+ const rows = this.db
395
+ .prepare(`SELECT s.* FROM spaces s JOIN memberships m ON m.space_id = s.id
396
+ WHERE m.user_id = ? ORDER BY s.kind = 'shared', s.name COLLATE NOCASE`)
397
+ .all(userId);
398
+ return rows.map(spaceFromRow);
399
+ }
400
+ listMemberships(spaceId) {
401
+ const rows = this.db.prepare("SELECT * FROM memberships WHERE space_id = ? ORDER BY joined_at, user_id").all(spaceId);
402
+ return rows.map(membershipFromRow);
403
+ }
404
+ findMembership(spaceId, userId) {
405
+ const row = this.db.prepare("SELECT * FROM memberships WHERE space_id = ? AND user_id = ?").get(spaceId, userId);
406
+ return row ? membershipFromRow(row) : null;
407
+ }
408
+ isMember(spaceId, userId) {
409
+ return this.findMembership(spaceId, userId) !== null;
410
+ }
411
+ // --- Tâches ---
412
+ findTask(id) {
413
+ const row = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
414
+ return row ? taskFromRow(row) : null;
415
+ }
416
+ getTask(id) {
417
+ const task = this.findTask(id);
418
+ if (!task)
419
+ throw new NotFoundError(`Task ${id} not found.`);
420
+ return task;
421
+ }
422
+ /** Tâches d'un espace, par profondeur, colonne et position. */
423
+ listTasks(spaceId) {
424
+ const rows = this.db.prepare("SELECT * FROM tasks WHERE space_id = ? ORDER BY depth, status, position").all(spaceId);
425
+ return rows.map(taskFromRow);
426
+ }
427
+ /** Tâche d'un espace par son numéro (`41` dans `ORG-41`). */
428
+ findTaskByNumber(spaceId, number) {
429
+ const row = this.db.prepare("SELECT * FROM tasks WHERE space_id = ? AND number = ?").get(spaceId, number);
430
+ return row ? taskFromRow(row) : null;
431
+ }
432
+ listChildren(parentId) {
433
+ const rows = this.db.prepare("SELECT * FROM tasks WHERE parent_id = ? ORDER BY status, position").all(parentId);
434
+ return rows.map(taskFromRow);
435
+ }
436
+ listAssigneeIds(taskId) {
437
+ const rows = this.db.prepare("SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY user_id").all(taskId);
438
+ return rows.map((row) => row.user_id);
439
+ }
440
+ listTagIds(taskId) {
441
+ const rows = this.db.prepare("SELECT tag_id FROM task_tags WHERE task_id = ? ORDER BY tag_id").all(taskId);
442
+ return rows.map((row) => row.tag_id);
443
+ }
444
+ /** Instantané d'une tâche : ses champs, ses assignés et ses tags. */
445
+ taskSnapshot(taskId) {
446
+ return TaskSnapshotSchema.parse({ ...this.getTask(taskId), assigneeIds: this.listAssigneeIds(taskId), tagIds: this.listTagIds(taskId) });
447
+ }
448
+ /** Assignations des tâches d'un espace. */
449
+ listAssignmentsInSpace(spaceId) {
450
+ const rows = this.db
451
+ .prepare("SELECT a.task_id, a.user_id FROM task_assignees a JOIN tasks t ON t.id = a.task_id WHERE t.space_id = ? ORDER BY a.user_id")
452
+ .all(spaceId);
453
+ return rows.map((row) => ({ taskId: row.task_id, userId: row.user_id }));
454
+ }
455
+ /** Associations tâche-tag des tâches d'un espace. */
456
+ listTaskTagsInSpace(spaceId) {
457
+ const rows = this.db
458
+ .prepare("SELECT x.task_id, x.tag_id FROM task_tags x JOIN tasks t ON t.id = x.task_id WHERE t.space_id = ? ORDER BY x.tag_id")
459
+ .all(spaceId);
460
+ return rows.map((row) => ({ taskId: row.task_id, tagId: row.tag_id }));
461
+ }
462
+ /** Tâches d'une colonne (espace, parent, statut) par position, sans `excludeId`. */
463
+ listColumn(spaceId, parentId, status, excludeId = null) {
464
+ const rows = this.db
465
+ .prepare("SELECT * FROM tasks WHERE space_id = ? AND parent_id IS ? AND status = ? AND id IS NOT ? ORDER BY position")
466
+ .all(spaceId, parentId, status, excludeId);
467
+ return rows.map(taskFromRow);
468
+ }
469
+ // --- Tags ---
470
+ findTag(id) {
471
+ const row = this.db.prepare("SELECT * FROM tags WHERE id = ?").get(id);
472
+ return row ? tagFromRow(row) : null;
473
+ }
474
+ /** Tags d'un espace, par nom. */
475
+ listTags(spaceId) {
476
+ const rows = this.db.prepare("SELECT * FROM tags WHERE space_id = ? ORDER BY name COLLATE NOCASE").all(spaceId);
477
+ return rows.map(tagFromRow);
478
+ }
479
+ // --- Checklist ---
480
+ findChecklistItem(id) {
481
+ const row = this.db.prepare("SELECT * FROM checklist_items WHERE id = ?").get(id);
482
+ return row ? checklistItemFromRow(row) : null;
483
+ }
484
+ getChecklistItem(id) {
485
+ const item = this.findChecklistItem(id);
486
+ if (!item)
487
+ throw new NotFoundError(`Checklist item ${id} not found.`);
488
+ return item;
489
+ }
490
+ listChecklist(taskId) {
491
+ const rows = this.db.prepare("SELECT * FROM checklist_items WHERE task_id = ? ORDER BY position").all(taskId);
492
+ return rows.map(checklistItemFromRow);
493
+ }
494
+ // --- Tâches de test ---
495
+ findCheckpoint(id) {
496
+ const row = this.db.prepare("SELECT * FROM test_checkpoints WHERE id = ?").get(id);
497
+ return row ? checkpointFromRow(row) : null;
498
+ }
499
+ getCheckpoint(id) {
500
+ const checkpoint = this.findCheckpoint(id);
501
+ if (!checkpoint)
502
+ throw new NotFoundError(`Checkpoint ${id} not found.`);
503
+ return checkpoint;
504
+ }
505
+ /** Points de vérification d'une tâche de test, dans l'ordre. */
506
+ listCheckpoints(taskId) {
507
+ const rows = this.db.prepare("SELECT * FROM test_checkpoints WHERE task_id = ? ORDER BY position").all(taskId);
508
+ return rows.map(checkpointFromRow);
509
+ }
510
+ /** Résultats saisis sur les points d'une tâche, tous testeurs confondus. */
511
+ listTestResults(taskId) {
512
+ const rows = this.db
513
+ .prepare("SELECT r.* FROM test_results r JOIN test_checkpoints c ON c.id = r.checkpoint_id WHERE c.task_id = ? ORDER BY c.position, r.tester_id")
514
+ .all(taskId);
515
+ return rows.map(testResultFromRow);
516
+ }
517
+ findTestResult(checkpointId, testerId) {
518
+ const row = this.db.prepare("SELECT * FROM test_results WHERE checkpoint_id = ? AND tester_id = ?").get(checkpointId, testerId);
519
+ return row ? testResultFromRow(row) : null;
520
+ }
521
+ /** Reviews d'une tâche : brouillons et reviews soumises, par date de soumission. */
522
+ listTestReviews(taskId) {
523
+ const rows = this.db.prepare("SELECT * FROM test_reviews WHERE task_id = ? ORDER BY submitted_at IS NULL, submitted_at, id").all(taskId);
524
+ return rows.map(testReviewFromRow);
525
+ }
526
+ /** Brouillon de review d'un testeur sur une tâche. */
527
+ findDraftReview(taskId, testerId) {
528
+ const row = this.db.prepare("SELECT * FROM test_reviews WHERE task_id = ? AND tester_id = ? AND submitted_at IS NULL").get(taskId, testerId);
529
+ return row ? testReviewFromRow(row) : null;
530
+ }
531
+ findTestReview(id) {
532
+ const row = this.db.prepare("SELECT * FROM test_reviews WHERE id = ?").get(id);
533
+ return row ? testReviewFromRow(row) : null;
534
+ }
535
+ /** Points de vérification des tâches d'un espace. */
536
+ listCheckpointsInSpace(spaceId) {
537
+ const rows = this.db
538
+ .prepare("SELECT c.id, c.task_id FROM test_checkpoints c JOIN tasks t ON t.id = c.task_id WHERE t.space_id = ? ORDER BY c.position")
539
+ .all(spaceId);
540
+ return rows.map((row) => ({ id: row.id, taskId: row.task_id }));
541
+ }
542
+ /** Résultats saisis sur les points des tâches d'un espace. */
543
+ listTestResultsInSpace(spaceId) {
544
+ const rows = this.db
545
+ .prepare(`SELECT c.task_id, r.checkpoint_id, r.tester_id, r.result FROM test_results r
546
+ JOIN test_checkpoints c ON c.id = r.checkpoint_id JOIN tasks t ON t.id = c.task_id WHERE t.space_id = ?`)
547
+ .all(spaceId);
548
+ return rows.map((row) => ({
549
+ taskId: row.task_id,
550
+ checkpointId: row.checkpoint_id,
551
+ testerId: row.tester_id,
552
+ result: row.result,
553
+ }));
554
+ }
555
+ // --- Commentaires ---
556
+ findComment(id) {
557
+ const row = this.db.prepare("SELECT * FROM comments WHERE id = ?").get(id);
558
+ return row ? commentFromRow(row) : null;
559
+ }
560
+ getComment(id) {
561
+ const comment = this.findComment(id);
562
+ if (!comment)
563
+ throw new NotFoundError(`Comment ${id} not found.`);
564
+ return comment;
565
+ }
566
+ /** Commentaires d'une tâche, du plus ancien au plus récent. */
567
+ listComments(taskId) {
568
+ const rows = this.db.prepare("SELECT * FROM comments WHERE task_id = ? ORDER BY created_at, id").all(taskId);
569
+ return rows.map(commentFromRow);
570
+ }
571
+ // --- Pièces jointes ---
572
+ findAttachment(id) {
573
+ const row = this.db.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
574
+ return row ? attachmentFromRow(row) : null;
575
+ }
576
+ getAttachment(id) {
577
+ const attachment = this.findAttachment(id);
578
+ if (!attachment)
579
+ throw new NotFoundError(`Attachment ${id} not found.`);
580
+ return attachment;
581
+ }
582
+ /** Pièces jointes d'une tâche, de la plus ancienne à la plus récente. */
583
+ listAttachments(taskId) {
584
+ const rows = this.db.prepare("SELECT * FROM attachments WHERE task_id = ? ORDER BY created_at, id").all(taskId);
585
+ return rows.map(attachmentFromRow);
586
+ }
587
+ /** Pièces jointes d'une tâche et de toutes ses sous-tâches. */
588
+ listAttachmentsInSubtree(taskId) {
589
+ const rows = this.db
590
+ .prepare(`WITH RECURSIVE subtree(id) AS (SELECT ? UNION ALL SELECT t.id FROM tasks t JOIN subtree s ON t.parent_id = s.id)
591
+ SELECT a.* FROM attachments a JOIN subtree s ON a.task_id = s.id`)
592
+ .all(taskId);
593
+ return rows.map(attachmentFromRow);
594
+ }
595
+ /** Pièces jointes des tâches d'un espace. */
596
+ listAttachmentsInSpace(spaceId) {
597
+ const rows = this.db
598
+ .prepare("SELECT a.* FROM attachments a JOIN tasks t ON t.id = a.task_id WHERE t.space_id = ?")
599
+ .all(spaceId);
600
+ return rows.map(attachmentFromRow);
601
+ }
602
+ // --- Idées ---
603
+ findIdea(id) {
604
+ const row = this.db.prepare("SELECT * FROM ideas WHERE id = ?").get(id);
605
+ return row ? ideaFromRow(row) : null;
606
+ }
607
+ getIdea(id) {
608
+ const idea = this.findIdea(id);
609
+ if (!idea)
610
+ throw new NotFoundError(`Idea ${id} not found.`);
611
+ return idea;
612
+ }
613
+ listIdeas(spaceId) {
614
+ const rows = this.db.prepare("SELECT * FROM ideas WHERE space_id = ? ORDER BY created_at DESC, id").all(spaceId);
615
+ return rows.map(ideaFromRow);
616
+ }
617
+ /** Demandes de promotion (`idea_promotion`) en attente ou en cours sur les idées d'un espace. */
618
+ listOpenPromotionRequests(spaceId) {
619
+ const rows = this.db
620
+ .prepare(`SELECT r.* FROM agent_requests r JOIN ideas i ON i.id = r.context_idea_id
621
+ WHERE i.space_id = ? AND r.kind = 'idea_promotion' AND r.status IN ('pending', 'processing') ORDER BY r.created_at, r.id`)
622
+ .all(spaceId);
623
+ return rows.map(requestFromRow);
624
+ }
625
+ /** Tâches de test qui vérifient une tâche. */
626
+ listTestsOf(taskId) {
627
+ const rows = this.db.prepare("SELECT * FROM tasks WHERE tested_task_id = ? ORDER BY number").all(taskId);
628
+ return rows.map(taskFromRow);
629
+ }
630
+ // --- Demandes ---
631
+ findAgentRequest(id) {
632
+ const row = this.db.prepare("SELECT * FROM agent_requests WHERE id = ?").get(id);
633
+ return row ? requestFromRow(row) : null;
634
+ }
635
+ getAgentRequest(id) {
636
+ const request = this.findAgentRequest(id);
637
+ if (!request)
638
+ throw new NotFoundError(`Request ${id} not found.`);
639
+ return request;
640
+ }
641
+ /**
642
+ * Demandes qui visent un espace, d'un statut (toutes sans statut), au plus
643
+ * `limit` : les demandes en attente de la plus ancienne à la plus récente,
644
+ * les autres de la plus récente à la plus ancienne.
645
+ */
646
+ listAgentRequestsTargeting(spaceId, status, limit) {
647
+ const order = status === "pending" ? "created_at, id" : "created_at DESC, id DESC";
648
+ const rows = this.db
649
+ .prepare(`SELECT * FROM agent_requests WHERE target_space_id = @spaceId AND (@status IS NULL OR status = @status) ORDER BY ${order} LIMIT @limit`)
650
+ .all({ spaceId, status, limit });
651
+ return rows.map(requestFromRow);
652
+ }
653
+ /** Demandes d'un espace, de la plus récente à la plus ancienne, au plus `limit`. */
654
+ listAgentRequestsOfSpace(spaceId, limit) {
655
+ const rows = this.db
656
+ .prepare("SELECT * FROM agent_requests WHERE target_space_id = @spaceId ORDER BY created_at DESC, id DESC LIMIT @limit")
657
+ .all({ spaceId, limit });
658
+ return rows.map(requestFromRow);
659
+ }
660
+ /** Nombre de demandes en attente d'un espace. */
661
+ countPendingRequestsOfSpace(spaceId) {
662
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM agent_requests WHERE status = 'pending' AND target_space_id = ?").get(spaceId);
663
+ return row.n;
664
+ }
665
+ // --- Actions de l'agent ---
666
+ findAgentAction(id) {
667
+ const row = this.db.prepare("SELECT * FROM agent_actions WHERE id = ?").get(id);
668
+ return row ? agentActionFromRow(row) : null;
669
+ }
670
+ getAgentAction(id) {
671
+ const action = this.findAgentAction(id);
672
+ if (!action)
673
+ throw new NotFoundError(`Agent action ${id} not found.`);
674
+ return action;
675
+ }
676
+ /** Actions d'une demande (son lot), dans l'ordre où l'agent les a faites. */
677
+ listAgentActions(requestId) {
678
+ const rows = this.db.prepare("SELECT * FROM agent_actions WHERE request_id = ? ORDER BY seq").all(requestId);
679
+ return rows.map(agentActionFromRow);
680
+ }
681
+ /** Dernière action non annulée de l'agent sur une entité. */
682
+ lastLiveAgentActionOn(entityType, entityId) {
683
+ const row = this.db
684
+ .prepare("SELECT * FROM agent_actions WHERE entity_type = ? AND entity_id = ? AND undone_at IS NULL ORDER BY created_at DESC, seq DESC LIMIT 1")
685
+ .get(entityType, entityId);
686
+ return row ? agentActionFromRow(row) : null;
687
+ }
688
+ /** Auteur du dernier changement enregistré d'une entité, ou null si elle n'en a aucun. */
689
+ lastChangeAuthor(entityType, entityId) {
690
+ const row = this.db
691
+ .prepare("SELECT actor_type, actor_id, request_id FROM changes WHERE entity_type = ? AND entity_id = ? AND actor_type IS NOT NULL ORDER BY seq DESC LIMIT 1")
692
+ .get(entityType, entityId);
693
+ if (!row)
694
+ return null;
695
+ return { actorType: row.actor_type, actorId: row.actor_id ?? null, requestId: row.request_id ?? null };
696
+ }
697
+ // --- Notifications ---
698
+ findNotification(id) {
699
+ const row = this.db.prepare("SELECT * FROM notifications WHERE id = ?").get(id);
700
+ return row ? notificationFromRow(row) : null;
701
+ }
702
+ getNotification(id) {
703
+ const notification = this.findNotification(id);
704
+ if (!notification)
705
+ throw new NotFoundError(`Notification ${id} not found.`);
706
+ return notification;
707
+ }
708
+ /**
709
+ * Notifications affichées dans l'application d'un utilisateur, sur les
710
+ * tâches des espaces dont il est encore membre : non lues d'abord, puis de
711
+ * la plus récente à la plus ancienne.
712
+ */
713
+ listNotifications(userId, limit, offset) {
714
+ const rows = this.db
715
+ .prepare(`SELECT n.* FROM notifications n JOIN tasks t ON t.id = n.task_id
716
+ JOIN memberships m ON m.space_id = t.space_id AND m.user_id = n.user_id
717
+ WHERE n.user_id = ? AND n.in_app = 1
718
+ ORDER BY n.read_at IS NOT NULL, n.created_at DESC, n.id DESC LIMIT ? OFFSET ?`)
719
+ .all(userId, limit, offset);
720
+ return rows.map(notificationFromRow);
721
+ }
722
+ /** Nombre de notifications non lues affichées dans l'application d'un utilisateur. */
723
+ countUnreadNotifications(userId) {
724
+ const row = this.db
725
+ .prepare(`SELECT COUNT(*) AS n FROM notifications n JOIN tasks t ON t.id = n.task_id
726
+ JOIN memberships m ON m.space_id = t.space_id AND m.user_id = n.user_id
727
+ WHERE n.user_id = ? AND n.in_app = 1 AND n.read_at IS NULL`)
728
+ .get(userId);
729
+ return row.n;
730
+ }
731
+ // --- Historique et journal ---
732
+ findVersion(entityType, entityId, version) {
733
+ const row = this.db
734
+ .prepare("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? AND version = ?")
735
+ .get(entityType, entityId, version);
736
+ return row ? versionFromRow(row) : null;
737
+ }
738
+ listVersions(entityType, entityId) {
739
+ const rows = this.db
740
+ .prepare("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? ORDER BY version")
741
+ .all(entityType, entityId);
742
+ return rows.map(versionFromRow);
743
+ }
744
+ /** Changements de numéro strictement supérieur à `since`, dans l'ordre. */
745
+ listChangesSince(since) {
746
+ const rows = this.db.prepare("SELECT * FROM changes WHERE seq > ? ORDER BY seq").all(since);
747
+ return rows.map(changeFromRow);
748
+ }
749
+ /**
750
+ * Changements postérieurs à `since` qui concernent un utilisateur : ceux des
751
+ * espaces dont il est membre et ceux qui le visent, au plus `limit`, dans l'ordre.
752
+ */
753
+ listChangesForUser(userId, since, limit) {
754
+ const rows = this.db
755
+ .prepare(`SELECT * FROM changes WHERE seq > ?
756
+ AND (user_id = ? OR space_id IN (SELECT space_id FROM memberships WHERE user_id = ?))
757
+ ORDER BY seq LIMIT ?`)
758
+ .all(since, userId, userId, limit);
759
+ return rows.map(changeFromRow);
760
+ }
761
+ /** Numéro du dernier changement, 0 si le journal est vide. */
762
+ lastChangeSeq() {
763
+ return this.db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM changes").get().seq;
764
+ }
765
+ // --- Mutation ---
766
+ /**
767
+ * Abonne `listener` aux notifications dont le destinataire veut un e-mail :
768
+ * il reçoit leurs identifiants après la validation de la mutation qui les a
769
+ * créées.
770
+ */
771
+ setEmailListener(listener) {
772
+ this.emailListener = listener;
773
+ }
774
+ /**
775
+ * Abonne `listener` aux demandes qui passent en attente (créées, ou
776
+ * libérées par un membre) : il les reçoit après la validation de la
777
+ * mutation. La fonction rendue désabonne.
778
+ */
779
+ onPendingRequest(listener) {
780
+ this.pendingRequestListeners.add(listener);
781
+ return () => this.pendingRequestListeners.delete(listener);
782
+ }
783
+ /**
784
+ * Point unique d'écriture. `fn` reçoit une `Mutation` et s'exécute dans une
785
+ * transaction exclusive ; à sa sortie, les invariants sont vérifiés sur l'état
786
+ * final, les versions et le journal sont écrits. Toute erreur annule
787
+ * l'ensemble.
788
+ */
789
+ mutate(actor, fn) {
790
+ if (this.activeMutation)
791
+ throw new Error("Store.mutate is not reentrant: use the Mutation passed to the running mutation.");
792
+ let emails = [];
793
+ let pending = [];
794
+ const run = this.db.transaction(() => {
795
+ const tx = new Mutation(this, actor);
796
+ this.activeMutation = tx;
797
+ try {
798
+ const result = fn(tx);
799
+ tx.finish();
800
+ emails = tx.emailNotificationIds;
801
+ pending = tx.pendingRequestIds;
802
+ return result;
803
+ }
804
+ finally {
805
+ this.activeMutation = null;
806
+ }
807
+ });
808
+ const result = run.immediate();
809
+ if (emails.length > 0 && this.emailListener)
810
+ this.emailListener(emails);
811
+ if (pending.length > 0 && this.pendingRequestListeners.size > 0) {
812
+ for (const id of pending) {
813
+ const request = this.getAgentRequest(id);
814
+ for (const listener of [...this.pendingRequestListeners])
815
+ listener(request);
816
+ }
817
+ }
818
+ return result;
819
+ }
820
+ }
821
+ const VERSIONED = new Set(["task", "idea"]);
822
+ /**
823
+ * Écritures d'une mutation en cours. Chaque méthode applique une modification
824
+ * et la note ; `finish` vérifie les invariants et écrit versions et journal.
825
+ */
826
+ export class Mutation {
827
+ store;
828
+ actor;
829
+ /** Horodatage commun à toutes les écritures de la mutation. */
830
+ now = new Date().toISOString();
831
+ touched = new Map();
832
+ /** Notifications créées par la mutation dont le destinataire veut un e-mail. */
833
+ emailNotificationIds = [];
834
+ /** Demandes passées en attente par la mutation : créées, ou libérées. */
835
+ pendingRequestIds = [];
836
+ /** Demande pour laquelle agit l'agent, lue au début de la mutation. */
837
+ request;
838
+ /** Jeton avec lequel agit l'agent. */
839
+ token;
840
+ /** Vrai une fois vérifié que l'agent peut écrire pour sa demande. */
841
+ agentWriteChecked = false;
842
+ /** Demandes créées par la mutation, dont l'espace cible est vérifié. */
843
+ createdRequestIds = new Set();
844
+ db;
845
+ constructor(store, actor) {
846
+ this.store = store;
847
+ this.actor = actor;
848
+ this.db = store.db;
849
+ if (actor.type === "agent") {
850
+ this.request = this.loadAgentRequest(actor.requestId);
851
+ this.token = this.store.findAgentToken(actor.tokenId);
852
+ if (!this.token)
853
+ throw new NotFoundError(`Agent token ${actor.tokenId} not found.`);
854
+ }
855
+ else {
856
+ this.request = null;
857
+ this.token = null;
858
+ }
859
+ }
860
+ /** Utilisateur au nom de qui la mutation s'exécute : l'utilisateur, ou le propriétaire du jeton de l'agent. */
861
+ get actingUserId() {
862
+ return this.actor.type === "user" ? this.actor.userId : this.token.userId;
863
+ }
864
+ loadAgentRequest(requestId) {
865
+ const request = this.store.findAgentRequest(requestId);
866
+ if (!request)
867
+ throw new NotFoundError(`Request ${requestId} not found. Call list_requests to get valid request ids.`);
868
+ return request;
869
+ }
870
+ /** La demande de l'agent vise l'espace de son jeton. */
871
+ assertRequestInTokenSpace(request, token) {
872
+ if (request.targetSpaceId !== token.spaceId) {
873
+ throw new ForbiddenError(`Request ${request.id} belongs to another space than the one your token covers. Call list_requests to get the requests of your space.`);
874
+ }
875
+ }
876
+ /** La demande est `processing` et prise par le jeton de l'agent. */
877
+ assertClaimedByToken(request, token) {
878
+ if (request.status === "pending")
879
+ throw new InvariantError(`Request ${request.id} is pending: call claim_request on it first.`);
880
+ if (request.status !== "processing") {
881
+ throw new InvariantError(`Request ${request.id} is ${request.status}, not processing: it is closed. Call list_requests to pick a pending request.`);
882
+ }
883
+ if (request.claimedByTokenId !== token.id) {
884
+ throw new ForbiddenError(`Request ${request.id} was claimed by another agent token: only that agent can act for it. Call list_requests to pick a pending request.`);
885
+ }
886
+ }
887
+ /**
888
+ * L'agent n'écrit que pour une demande `processing` prise par son jeton,
889
+ * dans l'espace du jeton, dont le propriétaire est toujours membre.
890
+ * Vérifié à la première écriture d'une entité autre que la demande elle-même.
891
+ */
892
+ assertAgentMayWrite() {
893
+ const request = this.request;
894
+ const token = this.token;
895
+ if (!request || !token || this.agentWriteChecked)
896
+ return;
897
+ this.assertRequestInTokenSpace(request, token);
898
+ this.assertClaimedByToken(request, token);
899
+ if (!this.store.isMember(request.targetSpaceId, token.userId)) {
900
+ throw new ForbiddenError(`The owner of your agent token is no longer a member of the space of request ${request.id}: the agent cannot act there.`);
901
+ }
902
+ this.agentWriteChecked = true;
903
+ }
904
+ touch(entityType, entityId, spaceId, userId, taskId = null) {
905
+ if (entityType !== "agent_request")
906
+ this.assertAgentMayWrite();
907
+ const key = `${entityType}:${entityId}`;
908
+ const concernedTask = entityType === "task" ? entityId : taskId;
909
+ if (!this.touched.has(key))
910
+ this.touched.set(key, { entityType, entityId, spaceId, userId, taskId: concernedTask });
911
+ }
912
+ // --- Utilisateurs ---
913
+ createUser(input) {
914
+ const user = validate(UserSchema, {
915
+ id: input.id ?? newId(),
916
+ email: input.email,
917
+ displayName: input.displayName ?? null,
918
+ passwordHash: input.passwordHash ?? null,
919
+ status: input.status,
920
+ createdAt: this.now,
921
+ }, "user");
922
+ if (user.status !== "invited" && user.displayName === null) {
923
+ throw new OrganizerError(`An ${user.status} account needs a display name.`);
924
+ }
925
+ if (this.store.findUserByEmail(user.email))
926
+ throw new InvariantError(`An account already exists for ${user.email}.`);
927
+ this.db
928
+ .prepare("INSERT INTO users (id, email, display_name, password_hash, status, created_at) VALUES (?, ?, ?, ?, ?, ?)")
929
+ .run(user.id, user.email, user.displayName, user.passwordHash, user.status, user.createdAt);
930
+ this.db.prepare("INSERT INTO user_settings (user_id) VALUES (?)").run(user.id);
931
+ this.touch("user", user.id, null, user.id);
932
+ return user;
933
+ }
934
+ updateUser(id, patch) {
935
+ const current = this.store.getUser(id);
936
+ const user = validate(UserSchema, { ...current, ...definedFields(patch) }, "user");
937
+ if (user.status !== "invited" && user.displayName === null) {
938
+ throw new OrganizerError(`An ${user.status} account needs a display name.`);
939
+ }
940
+ this.db
941
+ .prepare("UPDATE users SET display_name = ?, password_hash = ?, status = ? WHERE id = ?")
942
+ .run(user.displayName, user.passwordHash, user.status, id);
943
+ this.touch("user", id, null, id);
944
+ return user;
945
+ }
946
+ // --- Sessions et liens d'accès ---
947
+ createSession(session) {
948
+ const valid = validate(SessionSchema, session, "session");
949
+ this.store.getUser(valid.userId);
950
+ this.db
951
+ .prepare("INSERT INTO sessions (id_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)")
952
+ .run(valid.idHash, valid.userId, valid.createdAt, valid.expiresAt);
953
+ return valid;
954
+ }
955
+ extendSession(idHash, expiresAt) {
956
+ this.db.prepare("UPDATE sessions SET expires_at = ? WHERE id_hash = ?").run(expiresAt, idHash);
957
+ }
958
+ deleteSession(idHash) {
959
+ this.db.prepare("DELETE FROM sessions WHERE id_hash = ?").run(idHash);
960
+ }
961
+ /** Ferme les sessions d'un compte, sauf celle de `keepIdHash`. */
962
+ deleteSessionsOf(userId, keepIdHash = null) {
963
+ this.db.prepare("DELETE FROM sessions WHERE user_id = ? AND id_hash IS NOT ?").run(userId, keepIdHash);
964
+ }
965
+ /** Supprime les sessions expirées à l'instant `now`. */
966
+ deleteExpiredSessions(now) {
967
+ this.db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(now);
968
+ }
969
+ /**
970
+ * Émet un lien d'accès pour un compte. Un lien d'invitation est rattaché à
971
+ * son invitation, adressée à ce compte, et remplace le lien inutilisé de
972
+ * cette seule invitation ; un lien de réinitialisation remplace les liens de
973
+ * réinitialisation inutilisés du compte.
974
+ */
975
+ createAccessLink(input) {
976
+ const user = this.store.getUser(input.userId);
977
+ const link = validate(AccessLinkSchema, { id: newId(), ...input, usedAt: null, createdAt: this.now }, "access link");
978
+ if ((link.purpose === "invite") !== (link.invitationId !== null)) {
979
+ throw new InvariantError("An invitation link belongs to exactly one invitation, and only an invitation link does.");
980
+ }
981
+ if (link.invitationId !== null) {
982
+ const invitation = this.openInvitation(link.invitationId);
983
+ if (invitation.email !== user.email)
984
+ throw new InvariantError(`Invitation ${invitation.id} is not addressed to ${user.email}.`);
985
+ }
986
+ this.db
987
+ .prepare("DELETE FROM access_links WHERE user_id = ? AND purpose = ? AND invitation_id IS ? AND used_at IS NULL")
988
+ .run(link.userId, link.purpose, link.invitationId);
989
+ this.db
990
+ .prepare("INSERT INTO access_links (id, user_id, purpose, invitation_id, token_hash, expires_at, used_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
991
+ .run(link.id, link.userId, link.purpose, link.invitationId, link.tokenHash, link.expiresAt, link.usedAt, link.createdAt);
992
+ return link;
993
+ }
994
+ /** Supprime les liens inutilisés d'un compte, qui ne fonctionnent plus. */
995
+ discardUnusedAccessLinks(userId) {
996
+ this.db.prepare("DELETE FROM access_links WHERE user_id = ? AND used_at IS NULL").run(userId);
997
+ }
998
+ /** Supprime les liens inutilisés émis par une invitation. */
999
+ discardInvitationLinks(invitationId) {
1000
+ this.db.prepare("DELETE FROM access_links WHERE invitation_id = ? AND used_at IS NULL").run(invitationId);
1001
+ }
1002
+ markAccessLinkUsed(id) {
1003
+ const result = this.db.prepare("UPDATE access_links SET used_at = ? WHERE id = ? AND used_at IS NULL").run(this.now, id);
1004
+ if (result.changes !== 1)
1005
+ throw new InvariantError("This access link has already been used.");
1006
+ }
1007
+ // --- Jetons d'agent ---
1008
+ /** Enregistre un jeton d'agent de l'utilisateur acteur ; seul son hash est conservé. */
1009
+ createAgentToken(input) {
1010
+ if (this.actor.type !== "user")
1011
+ throw new InvariantError("Only a user can create an agent token.");
1012
+ const token = validate(AgentTokenSchema, { id: newId(), userId: this.actor.userId, ...input, createdAt: this.now, lastUsedAt: null, revokedAt: null }, "agent token");
1013
+ if (!this.store.isMember(token.spaceId, token.userId))
1014
+ throw new ForbiddenError("An agent token can only cover a space its owner is a member of.");
1015
+ this.db
1016
+ .prepare(`INSERT INTO agent_tokens (id, user_id, space_id, name, prefix, token_hash, created_at, expires_at)
1017
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
1018
+ .run(token.id, token.userId, token.spaceId, token.name, token.prefix, token.tokenHash, token.createdAt, token.expiresAt);
1019
+ this.touch("agent_token", token.id, null, token.userId);
1020
+ return token;
1021
+ }
1022
+ /** Révoque un jeton de l'utilisateur acteur ; un jeton déjà révoqué reste tel quel. */
1023
+ revokeAgentToken(id) {
1024
+ const token = this.store.findAgentToken(id);
1025
+ if (!token || this.actor.type !== "user" || token.userId !== this.actor.userId)
1026
+ throw new NotFoundError(`Agent token ${id} not found.`);
1027
+ if (token.revokedAt !== null)
1028
+ return token;
1029
+ this.db.prepare("UPDATE agent_tokens SET revoked_at = ? WHERE id = ?").run(this.now, id);
1030
+ this.touch("agent_token", id, null, token.userId);
1031
+ return { ...token, revokedAt: this.now };
1032
+ }
1033
+ /** Date du dernier usage d'un jeton ; le journal des changements n'en garde pas trace. */
1034
+ recordAgentTokenUse(id, at) {
1035
+ this.db.prepare("UPDATE agent_tokens SET last_used_at = ? WHERE id = ?").run(at, id);
1036
+ }
1037
+ // --- Invitations ---
1038
+ /** Invite une adresse dans un espace, jusqu'à `expiresAt`. */
1039
+ createInvitation(input) {
1040
+ const space = this.store.getSpace(input.spaceId);
1041
+ const invitation = validate(InvitationSchema, {
1042
+ id: newId(),
1043
+ spaceId: space.id,
1044
+ email: input.email,
1045
+ invitedBy: this.actingUserId,
1046
+ createdAt: this.now,
1047
+ expiresAt: input.expiresAt,
1048
+ acceptedAt: null,
1049
+ revokedAt: null,
1050
+ declinedAt: null,
1051
+ }, "invitation");
1052
+ const open = this.store.listOpenInvitations(space.id).find((existing) => existing.email === invitation.email);
1053
+ if (open)
1054
+ throw new InvariantError(`${invitation.email} already has a pending invitation to space "${space.name}".`);
1055
+ this.db
1056
+ .prepare("INSERT INTO invitations (id, space_id, email, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
1057
+ .run(invitation.id, invitation.spaceId, invitation.email, invitation.invitedBy, invitation.createdAt, invitation.expiresAt);
1058
+ this.touchInvitation(invitation);
1059
+ return invitation;
1060
+ }
1061
+ /** Repousse l'échéance d'une invitation en attente. */
1062
+ renewInvitation(id, expiresAt) {
1063
+ const invitation = this.openInvitation(id);
1064
+ const renewed = validate(InvitationSchema, { ...invitation, expiresAt }, "invitation");
1065
+ this.db.prepare("UPDATE invitations SET expires_at = ? WHERE id = ?").run(renewed.expiresAt, id);
1066
+ this.touchInvitation(invitation);
1067
+ return renewed;
1068
+ }
1069
+ /** Révoque une invitation en attente ; ses liens inutilisés ne fonctionnent plus. */
1070
+ revokeInvitation(id) {
1071
+ const invitation = this.openInvitation(id);
1072
+ this.db.prepare("UPDATE invitations SET revoked_at = ? WHERE id = ?").run(this.now, id);
1073
+ this.discardInvitationLinks(id);
1074
+ this.touchInvitation(invitation);
1075
+ return { ...invitation, revokedAt: this.now };
1076
+ }
1077
+ acceptInvitation(id) {
1078
+ const invitation = this.openInvitation(id);
1079
+ this.db.prepare("UPDATE invitations SET accepted_at = ? WHERE id = ?").run(this.now, id);
1080
+ this.touchInvitation(invitation);
1081
+ return { ...invitation, acceptedAt: this.now };
1082
+ }
1083
+ /** Refus d'une invitation en attente par son destinataire ; ses liens inutilisés ne fonctionnent plus. */
1084
+ declineInvitation(id) {
1085
+ const invitation = this.openInvitation(id);
1086
+ this.db.prepare("UPDATE invitations SET declined_at = ? WHERE id = ?").run(this.now, id);
1087
+ this.discardInvitationLinks(id);
1088
+ this.touchInvitation(invitation);
1089
+ return { ...invitation, declinedAt: this.now };
1090
+ }
1091
+ openInvitation(id) {
1092
+ const invitation = this.store.getInvitation(id);
1093
+ if (invitation.acceptedAt !== null)
1094
+ throw new InvariantError(`The invitation of ${invitation.email} has already been accepted.`);
1095
+ if (invitation.revokedAt !== null)
1096
+ throw new InvariantError(`The invitation of ${invitation.email} has been revoked.`);
1097
+ if (invitation.declinedAt !== null)
1098
+ throw new InvariantError(`The invitation of ${invitation.email} has been declined.`);
1099
+ return invitation;
1100
+ }
1101
+ /** Note la modification d'une invitation, pour son espace et pour le compte de son destinataire. */
1102
+ touchInvitation(invitation) {
1103
+ this.touch("invitation", invitation.id, invitation.spaceId, this.store.findUserByEmail(invitation.email)?.id ?? null);
1104
+ }
1105
+ // --- Espaces et membres ---
1106
+ /** Crée un espace et en fait de son propriétaire le membre `owner`. */
1107
+ createSpace(input) {
1108
+ const space = validate(SpaceSchema, { id: newId(), name: input.name, kind: input.kind, ownerId: input.ownerId, keyPrefix: input.keyPrefix, nextTaskNumber: 1, createdAt: this.now }, "space");
1109
+ this.store.getUser(space.ownerId);
1110
+ if (space.kind === "personal" && this.store.findPersonalSpace(space.ownerId)) {
1111
+ throw new InvariantError(`User ${space.ownerId} already has a personal space.`);
1112
+ }
1113
+ this.db
1114
+ .prepare("INSERT INTO spaces (id, name, kind, owner_id, key_prefix, next_task_number, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
1115
+ .run(space.id, space.name, space.kind, space.ownerId, space.keyPrefix, space.nextTaskNumber, space.createdAt);
1116
+ this.touch("space", space.id, space.id, null);
1117
+ this.insertMembership(space.id, space.ownerId, "owner");
1118
+ return space;
1119
+ }
1120
+ renameSpace(id, name) {
1121
+ const space = validate(SpaceSchema, { ...this.store.getSpace(id), name }, "space");
1122
+ this.db.prepare("UPDATE spaces SET name = ? WHERE id = ?").run(space.name, id);
1123
+ this.touch("space", id, id, null);
1124
+ return space;
1125
+ }
1126
+ /** Supprime un espace commun et tout son contenu. Un espace personnel ne se supprime pas. */
1127
+ deleteSpace(id) {
1128
+ const space = this.store.getSpace(id);
1129
+ if (space.kind === "personal")
1130
+ throw new InvariantError("A personal space cannot be deleted.");
1131
+ for (const membership of this.store.listMemberships(id)) {
1132
+ this.touch("membership", `${id}:${membership.userId}`, id, membership.userId);
1133
+ }
1134
+ this.db
1135
+ .prepare(`DELETE FROM entity_versions WHERE (entity_type = 'task' AND entity_id IN (SELECT id FROM tasks WHERE space_id = ?))
1136
+ OR (entity_type = 'idea' AND entity_id IN (SELECT id FROM ideas WHERE space_id = ?))`)
1137
+ .run(id, id);
1138
+ this.db.prepare("DELETE FROM spaces WHERE id = ?").run(id);
1139
+ this.touch("space", id, id, null);
1140
+ }
1141
+ addMember(spaceId, userId, role = "member") {
1142
+ this.store.getSpace(spaceId);
1143
+ this.store.getUser(userId);
1144
+ if (this.store.isMember(spaceId, userId))
1145
+ throw new InvariantError(`User ${userId} is already a member of space ${spaceId}.`);
1146
+ return this.insertMembership(spaceId, userId, role);
1147
+ }
1148
+ setMemberRole(spaceId, userId, role) {
1149
+ const membership = this.store.findMembership(spaceId, userId);
1150
+ if (!membership)
1151
+ throw new NotFoundError(`User ${userId} is not a member of space ${spaceId}.`);
1152
+ this.db.prepare("UPDATE memberships SET role = ? WHERE space_id = ? AND user_id = ?").run(role, spaceId, userId);
1153
+ this.touch("membership", `${spaceId}:${userId}`, spaceId, userId);
1154
+ return { ...membership, role };
1155
+ }
1156
+ /** Retire un membre d'un espace et révoque ses jetons d'agent sur cet espace. */
1157
+ removeMember(spaceId, userId) {
1158
+ if (!this.store.findMembership(spaceId, userId))
1159
+ throw new NotFoundError(`User ${userId} is not a member of space ${spaceId}.`);
1160
+ this.db.prepare("DELETE FROM memberships WHERE space_id = ? AND user_id = ?").run(spaceId, userId);
1161
+ this.touch("membership", `${spaceId}:${userId}`, spaceId, userId);
1162
+ const tokenIds = this.db.prepare("SELECT id FROM agent_tokens WHERE space_id = ? AND user_id = ? AND revoked_at IS NULL").pluck().all(spaceId, userId);
1163
+ this.db.prepare("UPDATE agent_tokens SET revoked_at = ? WHERE space_id = ? AND user_id = ? AND revoked_at IS NULL").run(this.now, spaceId, userId);
1164
+ for (const id of tokenIds)
1165
+ this.touch("agent_token", id, null, userId);
1166
+ }
1167
+ insertMembership(spaceId, userId, role) {
1168
+ const membership = validate(MembershipSchema, { spaceId, userId, role, joinedAt: this.now }, "membership");
1169
+ this.db.prepare("INSERT INTO memberships (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)").run(spaceId, userId, role, this.now);
1170
+ this.touch("membership", `${spaceId}:${userId}`, spaceId, userId);
1171
+ return membership;
1172
+ }
1173
+ // --- Tâches ---
1174
+ createTask(input) {
1175
+ const space = this.store.getSpace(input.spaceId);
1176
+ const parentId = input.parentId ?? null;
1177
+ const depth = parentId === null ? 0 : this.childDepth(this.store.getTask(parentId), space.id);
1178
+ const status = input.status ?? "todo";
1179
+ const task = validate(TaskSchema, {
1180
+ id: newId(),
1181
+ spaceId: space.id,
1182
+ number: space.nextTaskNumber,
1183
+ parentId,
1184
+ depth,
1185
+ type: input.type ?? "standard",
1186
+ title: input.title,
1187
+ description: input.description ?? "",
1188
+ status,
1189
+ priority: input.priority ?? "medium",
1190
+ position: input.position ?? this.endOfColumn(space.id, parentId, status, null),
1191
+ testedTaskId: null,
1192
+ createdBy: this.actingUserId,
1193
+ createdAt: this.now,
1194
+ updatedAt: this.now,
1195
+ version: 1,
1196
+ }, "task");
1197
+ this.db
1198
+ .prepare(`INSERT INTO tasks (id, space_id, number, parent_id, depth, type, title, description, status, priority, position,
1199
+ created_by, created_at, updated_at, version)
1200
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1201
+ .run(task.id, task.spaceId, task.number, task.parentId, task.depth, task.type, task.title, task.description, task.status, task.priority, task.position, task.createdBy, task.createdAt, task.updatedAt, task.version);
1202
+ this.db.prepare("UPDATE spaces SET next_task_number = next_task_number + 1 WHERE id = ?").run(space.id);
1203
+ this.touch("task", task.id, task.spaceId, null);
1204
+ return task;
1205
+ }
1206
+ /**
1207
+ * Modifie une tâche. Un changement de parent recalcule la profondeur de toute
1208
+ * la sous-arborescence ; un changement de colonne sans position place la
1209
+ * tâche en fin de colonne. Une tâche qui devient standard perd sa tâche testée.
1210
+ */
1211
+ updateTask(id, patch) {
1212
+ const current = this.store.getTask(id);
1213
+ const fields = definedFields(patch);
1214
+ const parentId = fields.parentId === undefined ? current.parentId : fields.parentId;
1215
+ const status = fields.status ?? current.status;
1216
+ let depth = current.depth;
1217
+ if (parentId !== current.parentId) {
1218
+ depth = parentId === null ? 0 : this.childDepth(this.store.getTask(parentId), current.spaceId);
1219
+ if (parentId !== null && this.isInSubtree(parentId, id)) {
1220
+ throw new InvariantError(`Task ${taskLabel(this.db, current)} cannot be moved under itself or one of its own sub-tasks.`);
1221
+ }
1222
+ const height = this.subtreeHeight(id);
1223
+ if (depth + height > MAX_TASK_DEPTH) {
1224
+ throw new InvariantError(`Moving task ${taskLabel(this.db, current)} there would nest its sub-tasks deeper than ${MAX_TASK_DEPTH} levels. Move or flatten its sub-tasks first.`);
1225
+ }
1226
+ }
1227
+ const columnChanged = parentId !== current.parentId || status !== current.status;
1228
+ const position = fields.position ?? (columnChanged ? this.endOfColumn(current.spaceId, parentId, status, id) : current.position);
1229
+ const type = fields.type ?? current.type;
1230
+ const testedTaskId = fields.testedTaskId !== undefined ? fields.testedTaskId : type === "standard" ? null : current.testedTaskId;
1231
+ const task = validate(TaskSchema, { ...current, ...fields, parentId, depth, status, position, testedTaskId, updatedAt: this.now }, "task");
1232
+ this.db
1233
+ .prepare(`UPDATE tasks SET parent_id = ?, depth = ?, type = ?, title = ?, description = ?, status = ?, priority = ?,
1234
+ position = ?, tested_task_id = ?, updated_at = ? WHERE id = ?`)
1235
+ .run(task.parentId, task.depth, task.type, task.title, task.description, task.status, task.priority, task.position, task.testedTaskId, task.updatedAt, id);
1236
+ this.touch("task", id, task.spaceId, null);
1237
+ if (task.depth !== current.depth)
1238
+ this.updateDescendantDepths(id, task.depth);
1239
+ return task;
1240
+ }
1241
+ /**
1242
+ * Supprime une tâche et ses sous-tâches ; chacune reçoit une version de
1243
+ * suppression. Les tâches de test qui vérifiaient une tâche supprimée
1244
+ * perdent ce lien, avec une nouvelle version.
1245
+ */
1246
+ deleteTask(id) {
1247
+ const task = this.store.getTask(id);
1248
+ for (const child of this.store.listChildren(id))
1249
+ this.deleteTask(child.id);
1250
+ for (const test of this.store.listTestsOf(id)) {
1251
+ if (test.id === id)
1252
+ continue;
1253
+ this.db.prepare("UPDATE tasks SET tested_task_id = NULL, updated_at = ? WHERE id = ?").run(this.now, test.id);
1254
+ this.touch("task", test.id, test.spaceId, null);
1255
+ }
1256
+ this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
1257
+ this.touch("task", id, task.spaceId, null);
1258
+ }
1259
+ /** Remplace les assignés (ou testeurs) d'une tâche. */
1260
+ setAssignees(taskId, userIds) {
1261
+ const task = this.store.getTask(taskId);
1262
+ const unique = [...new Set(userIds)].sort();
1263
+ for (const userId of unique)
1264
+ this.store.getUser(userId);
1265
+ const previous = new Set(this.store.listAssigneeIds(taskId));
1266
+ this.db.prepare("DELETE FROM task_assignees WHERE task_id = ?").run(taskId);
1267
+ const insert = this.db.prepare("INSERT INTO task_assignees (task_id, user_id) VALUES (?, ?)");
1268
+ for (const userId of unique)
1269
+ insert.run(taskId, userId);
1270
+ this.db.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?").run(this.now, taskId);
1271
+ this.touch("task", taskId, task.spaceId, null);
1272
+ for (const userId of unique) {
1273
+ if (previous.has(userId) || this.isActingUser(userId))
1274
+ continue;
1275
+ this.notify("assigned", userId, taskId, null);
1276
+ }
1277
+ return unique;
1278
+ }
1279
+ /**
1280
+ * Déplace une tâche dans la colonne `status` de son parent, juste après
1281
+ * `afterId` (null : en tête de colonne). Son rang est pris entre ses deux
1282
+ * nouvelles voisines ; quand leur écart est trop faible, la colonne est
1283
+ * renumérotée.
1284
+ */
1285
+ moveTask(id, status, afterId) {
1286
+ const task = this.store.getTask(id);
1287
+ const column = this.store.listColumn(task.spaceId, task.parentId, status, id);
1288
+ const position = this.slotAfter(column, afterId, `Task ${afterId} is not in the ${status} column of task ${taskLabel(this.db, task)}`, (ids) => {
1289
+ const update = this.db.prepare("UPDATE tasks SET position = ? WHERE id = ?");
1290
+ ids.forEach((taskId, index) => {
1291
+ update.run(index + 1, taskId);
1292
+ this.touch("task", taskId, task.spaceId, null);
1293
+ });
1294
+ });
1295
+ return this.updateTask(id, { status, position });
1296
+ }
1297
+ /** Remplace les tags d'une tâche ; un nom absent de l'espace y crée le tag (noms comparés sans la casse). */
1298
+ setTags(taskId, names) {
1299
+ const task = this.store.getTask(taskId);
1300
+ const existing = this.store.listTags(task.spaceId);
1301
+ const tags = [];
1302
+ for (const raw of names) {
1303
+ const name = raw.trim();
1304
+ if (tags.some((tag) => tag.name.toLowerCase() === name.toLowerCase()))
1305
+ continue;
1306
+ tags.push(existing.find((tag) => tag.name.toLowerCase() === name.toLowerCase()) ?? this.createTag(task.spaceId, name));
1307
+ }
1308
+ this.db.prepare("DELETE FROM task_tags WHERE task_id = ?").run(taskId);
1309
+ const insert = this.db.prepare("INSERT INTO task_tags (task_id, tag_id) VALUES (?, ?)");
1310
+ for (const tag of tags)
1311
+ insert.run(taskId, tag.id);
1312
+ this.db.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?").run(this.now, taskId);
1313
+ this.touch("task", taskId, task.spaceId, null);
1314
+ return tags;
1315
+ }
1316
+ createTag(spaceId, name) {
1317
+ const tag = validate(TagSchema, { id: newId(), spaceId, name }, "tag");
1318
+ this.db.prepare("INSERT INTO tags (id, space_id, name) VALUES (?, ?, ?)").run(tag.id, tag.spaceId, tag.name);
1319
+ this.touch("tag", tag.id, spaceId, null);
1320
+ return tag;
1321
+ }
1322
+ // --- Checklist ---
1323
+ /** Ajoute un élément en fin de checklist d'une tâche standard. */
1324
+ addChecklistItem(taskId, text) {
1325
+ const task = this.store.getTask(taskId);
1326
+ if (task.type !== "standard") {
1327
+ throw new InvariantError(`Task ${taskLabel(this.db, task)} is a test: it has checkpoints, not a checklist.`);
1328
+ }
1329
+ const items = this.store.listChecklist(taskId);
1330
+ const item = validate(ChecklistItemSchema, { id: newId(), taskId, text, done: false, position: positionBetween(items.at(-1)?.position ?? null, null) }, "checklist item");
1331
+ this.db
1332
+ .prepare("INSERT INTO checklist_items (id, task_id, text, done, position) VALUES (?, ?, ?, ?, ?)")
1333
+ .run(item.id, item.taskId, item.text, item.done ? 1 : 0, item.position);
1334
+ this.touch("checklist_item", item.id, task.spaceId, null, taskId);
1335
+ return item;
1336
+ }
1337
+ updateChecklistItem(id, patch) {
1338
+ const current = this.store.getChecklistItem(id);
1339
+ const item = validate(ChecklistItemSchema, { ...current, ...definedFields(patch) }, "checklist item");
1340
+ this.db.prepare("UPDATE checklist_items SET text = ?, done = ?, position = ? WHERE id = ?").run(item.text, item.done ? 1 : 0, item.position, id);
1341
+ this.touch("checklist_item", id, this.store.getTask(item.taskId).spaceId, null, item.taskId);
1342
+ return item;
1343
+ }
1344
+ deleteChecklistItem(id) {
1345
+ const item = this.store.getChecklistItem(id);
1346
+ this.db.prepare("DELETE FROM checklist_items WHERE id = ?").run(id);
1347
+ this.touch("checklist_item", id, this.store.getTask(item.taskId).spaceId, null, item.taskId);
1348
+ }
1349
+ /** Recrée un élément de checklist supprimé, avec son identifiant et sa position. */
1350
+ restoreChecklistItem(snapshot) {
1351
+ const item = validate(ChecklistItemSchema, snapshot, "checklist item");
1352
+ const task = this.store.getTask(item.taskId);
1353
+ if (task.type !== "standard")
1354
+ throw new InvariantError(`Task ${taskLabel(this.db, task)} is a test: it has checkpoints, not a checklist.`);
1355
+ this.db
1356
+ .prepare("INSERT INTO checklist_items (id, task_id, text, done, position) VALUES (?, ?, ?, ?, ?)")
1357
+ .run(item.id, item.taskId, item.text, item.done ? 1 : 0, item.position);
1358
+ this.touch("checklist_item", item.id, task.spaceId, null, task.id);
1359
+ return item;
1360
+ }
1361
+ /** Place un élément de checklist juste après `afterId` (null : en tête), en renumérotant la liste si besoin. */
1362
+ moveChecklistItem(id, afterId) {
1363
+ const item = this.store.getChecklistItem(id);
1364
+ const spaceId = this.store.getTask(item.taskId).spaceId;
1365
+ const others = this.store.listChecklist(item.taskId).filter((other) => other.id !== id);
1366
+ const position = this.slotAfter(others, afterId, `Checklist item ${afterId} is not in the same checklist`, (ids) => {
1367
+ const update = this.db.prepare("UPDATE checklist_items SET position = ? WHERE id = ?");
1368
+ ids.forEach((itemId, index) => {
1369
+ update.run(index + 1, itemId);
1370
+ this.touch("checklist_item", itemId, spaceId, null, item.taskId);
1371
+ });
1372
+ });
1373
+ return this.updateChecklistItemPosition(item, position, spaceId);
1374
+ }
1375
+ updateChecklistItemPosition(item, position, spaceId) {
1376
+ this.db.prepare("UPDATE checklist_items SET position = ? WHERE id = ?").run(position, item.id);
1377
+ this.touch("checklist_item", item.id, spaceId, null, item.taskId);
1378
+ return { ...item, position };
1379
+ }
1380
+ /**
1381
+ * Rang d'un élément inséré juste après `afterId` (null : en tête) dans
1382
+ * `items`, triés par rang. Quand les voisins sont trop proches, `renumber`
1383
+ * reçoit les identifiants dans l'ordre et leur donne les rangs 1, 2, 3…
1384
+ */
1385
+ slotAfter(items, afterId, notFound, renumber) {
1386
+ let index = 0;
1387
+ if (afterId !== null) {
1388
+ const at = items.findIndex((item) => item.id === afterId);
1389
+ if (at < 0)
1390
+ throw new InvariantError(`${notFound}: reload and try again.`);
1391
+ index = at + 1;
1392
+ }
1393
+ const position = positionBetween(index > 0 ? items[index - 1].position : null, index < items.length ? items[index].position : null);
1394
+ if (position !== null)
1395
+ return position;
1396
+ renumber(items.map((item) => item.id));
1397
+ return positionBetween(index > 0 ? index : null, index < items.length ? index + 1 : null);
1398
+ }
1399
+ // --- Tâches de test ---
1400
+ /** Tâche de test qui porte des points de vérification. */
1401
+ testTask(taskId) {
1402
+ const task = this.store.getTask(taskId);
1403
+ if (task.type !== "test") {
1404
+ throw new InvariantError(`Task ${taskLabel(this.db, task)} is a standard task: it has a checklist, not test checkpoints.`);
1405
+ }
1406
+ return task;
1407
+ }
1408
+ /** Tâche de test dont l'utilisateur acteur est testeur (assigné). */
1409
+ testedTask(taskId) {
1410
+ const task = this.testTask(taskId);
1411
+ if (!this.store.listAssigneeIds(taskId).includes(this.actingUserId)) {
1412
+ throw new ForbiddenError(`Only the testers (assignees) of task ${taskLabel(this.db, task)} can record results and reviews; assign the user first.`);
1413
+ }
1414
+ return task;
1415
+ }
1416
+ /** Ajoute un point de vérification en fin de liste d'une tâche de test. */
1417
+ addCheckpoint(taskId, text) {
1418
+ const task = this.testTask(taskId);
1419
+ const checkpoints = this.store.listCheckpoints(taskId);
1420
+ const checkpoint = validate(TestCheckpointSchema, { id: newId(), taskId, text, position: positionBetween(checkpoints.at(-1)?.position ?? null, null) }, "checkpoint");
1421
+ this.db
1422
+ .prepare("INSERT INTO test_checkpoints (id, task_id, text, position) VALUES (?, ?, ?, ?)")
1423
+ .run(checkpoint.id, checkpoint.taskId, checkpoint.text, checkpoint.position);
1424
+ this.touch("test_checkpoint", checkpoint.id, task.spaceId, null, taskId);
1425
+ return checkpoint;
1426
+ }
1427
+ updateCheckpoint(id, text) {
1428
+ const current = this.store.getCheckpoint(id);
1429
+ const checkpoint = validate(TestCheckpointSchema, { ...current, text }, "checkpoint");
1430
+ this.db.prepare("UPDATE test_checkpoints SET text = ? WHERE id = ?").run(checkpoint.text, id);
1431
+ this.touch("test_checkpoint", id, this.store.getTask(checkpoint.taskId).spaceId, null, checkpoint.taskId);
1432
+ return checkpoint;
1433
+ }
1434
+ /** Supprime un point de vérification et les résultats saisis dessus. */
1435
+ deleteCheckpoint(id) {
1436
+ const checkpoint = this.store.getCheckpoint(id);
1437
+ this.db.prepare("DELETE FROM test_checkpoints WHERE id = ?").run(id);
1438
+ this.touch("test_checkpoint", id, this.store.getTask(checkpoint.taskId).spaceId, null, checkpoint.taskId);
1439
+ }
1440
+ /** Recrée un point de vérification supprimé, avec son identifiant, sa position et les résultats saisis dessus. */
1441
+ restoreCheckpoint(snapshot, results) {
1442
+ const checkpoint = validate(TestCheckpointSchema, snapshot, "checkpoint");
1443
+ const task = this.testTask(checkpoint.taskId);
1444
+ this.db
1445
+ .prepare("INSERT INTO test_checkpoints (id, task_id, text, position) VALUES (?, ?, ?, ?)")
1446
+ .run(checkpoint.id, checkpoint.taskId, checkpoint.text, checkpoint.position);
1447
+ this.touch("test_checkpoint", checkpoint.id, task.spaceId, null, task.id);
1448
+ const insert = this.db.prepare("INSERT INTO test_results (checkpoint_id, tester_id, result, comment, updated_at) VALUES (?, ?, ?, ?, ?)");
1449
+ for (const raw of results) {
1450
+ const result = validate(TestResultSchema, raw, "test result");
1451
+ if (result.checkpointId !== checkpoint.id)
1452
+ throw new InvariantError(`Test result of checkpoint ${result.checkpointId} cannot be restored on checkpoint ${checkpoint.id}.`);
1453
+ insert.run(result.checkpointId, result.testerId, result.result, result.comment, result.updatedAt);
1454
+ this.touch("test_result", `${result.checkpointId}:${result.testerId}`, task.spaceId, null, task.id);
1455
+ }
1456
+ return checkpoint;
1457
+ }
1458
+ /** Place un point de vérification juste après `afterId` (null : en tête), en renumérotant la liste si besoin. */
1459
+ moveCheckpoint(id, afterId) {
1460
+ const checkpoint = this.store.getCheckpoint(id);
1461
+ const spaceId = this.store.getTask(checkpoint.taskId).spaceId;
1462
+ const others = this.store.listCheckpoints(checkpoint.taskId).filter((other) => other.id !== id);
1463
+ const update = this.db.prepare("UPDATE test_checkpoints SET position = ? WHERE id = ?");
1464
+ const position = this.slotAfter(others, afterId, `Checkpoint ${afterId} is not in the same test`, (ids) => {
1465
+ ids.forEach((checkpointId, index) => {
1466
+ update.run(index + 1, checkpointId);
1467
+ this.touch("test_checkpoint", checkpointId, spaceId, null, checkpoint.taskId);
1468
+ });
1469
+ });
1470
+ update.run(position, id);
1471
+ this.touch("test_checkpoint", id, spaceId, null, checkpoint.taskId);
1472
+ return { ...checkpoint, position };
1473
+ }
1474
+ /**
1475
+ * Enregistre le résultat de l'utilisateur acteur sur un point : valeur,
1476
+ * commentaire, ou les deux. Seul un testeur de la tâche saisit ses propres
1477
+ * résultats ; un point sans résultat vaut « untested ».
1478
+ */
1479
+ setTestResult(checkpointId, patch) {
1480
+ const checkpoint = this.store.getCheckpoint(checkpointId);
1481
+ const task = this.testedTask(checkpoint.taskId);
1482
+ const testerId = this.actingUserId;
1483
+ const current = this.store.findTestResult(checkpointId, testerId);
1484
+ const result = validate(TestResultSchema, {
1485
+ checkpointId,
1486
+ testerId,
1487
+ result: patch.result ?? current?.result ?? "untested",
1488
+ comment: patch.comment ?? current?.comment ?? "",
1489
+ updatedAt: this.now,
1490
+ }, "test result");
1491
+ this.db
1492
+ .prepare(`INSERT INTO test_results (checkpoint_id, tester_id, result, comment, updated_at) VALUES (?, ?, ?, ?, ?)
1493
+ ON CONFLICT (checkpoint_id, tester_id) DO UPDATE SET result = excluded.result, comment = excluded.comment, updated_at = excluded.updated_at`)
1494
+ .run(result.checkpointId, result.testerId, result.result, result.comment, result.updatedAt);
1495
+ this.touch("test_result", `${checkpointId}:${testerId}`, task.spaceId, null, task.id);
1496
+ return result;
1497
+ }
1498
+ /** Enregistre le brouillon de review de l'utilisateur acteur, testeur de la tâche. */
1499
+ saveReviewDraft(taskId, text) {
1500
+ const task = this.testedTask(taskId);
1501
+ const draft = this.store.findDraftReview(taskId, this.actingUserId);
1502
+ const review = validate(TestReviewSchema, draft ? { ...draft, text } : { id: newId(), taskId, testerId: this.actingUserId, text, submittedAt: null, requestId: null }, "test review");
1503
+ if (draft) {
1504
+ this.db.prepare("UPDATE test_reviews SET text = ? WHERE id = ?").run(review.text, review.id);
1505
+ }
1506
+ else {
1507
+ this.db
1508
+ .prepare("INSERT INTO test_reviews (id, task_id, tester_id, text, submitted_at, request_id) VALUES (?, ?, ?, ?, NULL, NULL)")
1509
+ .run(review.id, review.taskId, review.testerId, review.text);
1510
+ }
1511
+ this.touch("test_review", review.id, task.spaceId, null, taskId);
1512
+ return review;
1513
+ }
1514
+ /**
1515
+ * Soumet la review de l'utilisateur acteur avec le texte `text` : le
1516
+ * brouillon est figé (date de soumission) et une demande `test_review` est
1517
+ * déposée pour l'agent, dans l'espace de la tâche, avec la tâche pour contexte.
1518
+ */
1519
+ submitReview(taskId, text) {
1520
+ const task = this.testedTask(taskId);
1521
+ if (text.trim() === "")
1522
+ throw new OrganizerError(`The review of task ${taskLabel(this.db, task)} is empty: write what you observed before submitting it.`);
1523
+ const draft = this.saveReviewDraft(taskId, text.trim());
1524
+ const request = this.createAgentRequest({
1525
+ targetSpaceId: task.spaceId,
1526
+ kind: "test_review",
1527
+ text: draft.text,
1528
+ contextTaskId: task.id,
1529
+ });
1530
+ this.db.prepare("UPDATE test_reviews SET submitted_at = ?, request_id = ? WHERE id = ?").run(this.now, request.id, draft.id);
1531
+ return { review: { ...draft, submittedAt: this.now, requestId: request.id }, request };
1532
+ }
1533
+ // --- Commentaires ---
1534
+ /** Commentaire de l'utilisateur acteur sur une tâche. */
1535
+ addComment(taskId, body) {
1536
+ const task = this.store.getTask(taskId);
1537
+ const comment = validate(CommentSchema, { id: newId(), taskId, authorId: this.actingUserId, body, createdAt: this.now, editedAt: null, requestId: this.request?.id ?? null }, "comment");
1538
+ this.db
1539
+ .prepare("INSERT INTO comments (id, task_id, author_id, body, created_at, edited_at, request_id) VALUES (?, ?, ?, ?, ?, ?, ?)")
1540
+ .run(comment.id, comment.taskId, comment.authorId, comment.body, comment.createdAt, comment.editedAt, comment.requestId);
1541
+ this.touch("comment", comment.id, task.spaceId, null, taskId);
1542
+ const commenters = this.store.listComments(taskId).filter((other) => other.id !== comment.id).map((other) => other.authorId);
1543
+ const recipients = [...new Set([...this.store.listAssigneeIds(taskId), ...commenters])].sort();
1544
+ for (const userId of recipients) {
1545
+ if (this.isActingUser(userId) || !this.store.isMember(task.spaceId, userId))
1546
+ continue;
1547
+ this.notify("comment", userId, taskId, comment.id);
1548
+ }
1549
+ return comment;
1550
+ }
1551
+ /** Modifie un commentaire ; seul son auteur le peut. */
1552
+ updateComment(id, body) {
1553
+ const current = this.authoredComment(id, "edit");
1554
+ const comment = validate(CommentSchema, { ...current, body, editedAt: this.now }, "comment");
1555
+ this.db.prepare("UPDATE comments SET body = ?, edited_at = ? WHERE id = ?").run(comment.body, comment.editedAt, id);
1556
+ this.touch("comment", id, this.store.getTask(comment.taskId).spaceId, null, comment.taskId);
1557
+ return comment;
1558
+ }
1559
+ /** Supprime un commentaire ; seul son auteur le peut. */
1560
+ deleteComment(id) {
1561
+ const comment = this.authoredComment(id, "delete");
1562
+ this.db.prepare("DELETE FROM comments WHERE id = ?").run(id);
1563
+ this.touch("comment", id, this.store.getTask(comment.taskId).spaceId, null, comment.taskId);
1564
+ }
1565
+ /** Supprime un commentaire écrit par l'agent, quel que soit l'utilisateur acteur : annulation de l'action qui l'a créé. */
1566
+ deleteAgentComment(id) {
1567
+ const comment = this.store.getComment(id);
1568
+ if (comment.requestId === null)
1569
+ throw new ForbiddenError("Only a comment written by the agent can be removed by undoing its action.");
1570
+ this.db.prepare("DELETE FROM comments WHERE id = ?").run(id);
1571
+ this.touch("comment", id, this.store.getTask(comment.taskId).spaceId, null, comment.taskId);
1572
+ }
1573
+ authoredComment(id, action) {
1574
+ const comment = this.store.getComment(id);
1575
+ if (comment.authorId !== this.actingUserId)
1576
+ throw new ForbiddenError(`Only the author of a comment can ${action} it.`);
1577
+ return comment;
1578
+ }
1579
+ // --- Pièces jointes ---
1580
+ /** Enregistre une pièce jointe dont le fichier est déjà écrit sous `storagePath`. */
1581
+ createAttachment(input) {
1582
+ const task = this.store.getTask(input.taskId);
1583
+ const attachment = validate(AttachmentSchema, { ...input, uploaderId: this.actingUserId, createdAt: this.now }, "attachment");
1584
+ this.db
1585
+ .prepare("INSERT INTO attachments (id, task_id, uploader_id, filename, mime, size, storage_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
1586
+ .run(attachment.id, attachment.taskId, attachment.uploaderId, attachment.filename, attachment.mime, attachment.size, attachment.storagePath, attachment.createdAt);
1587
+ this.touch("attachment", attachment.id, task.spaceId, null, task.id);
1588
+ return attachment;
1589
+ }
1590
+ /** Supprime l'enregistrement d'une pièce jointe et le renvoie ; son fichier reste à effacer. */
1591
+ deleteAttachment(id) {
1592
+ const attachment = this.store.getAttachment(id);
1593
+ this.db.prepare("DELETE FROM attachments WHERE id = ?").run(id);
1594
+ this.touch("attachment", id, this.store.getTask(attachment.taskId).spaceId, null, attachment.taskId);
1595
+ return attachment;
1596
+ }
1597
+ /** Profondeur d'un enfant de `parent`, qui doit être dans l'espace `spaceId` et laisser place à un niveau de plus. */
1598
+ childDepth(parent, spaceId) {
1599
+ if (parent.spaceId !== spaceId)
1600
+ throw new InvariantError(`Parent task ${taskLabel(this.db, parent)} belongs to another space.`);
1601
+ if (parent.depth >= MAX_TASK_DEPTH) {
1602
+ throw new InvariantError(`Task ${taskLabel(this.db, parent)} is already at depth ${parent.depth}: sub-tasks are limited to ${MAX_TASK_DEPTH} levels (task > sub-task > sub-sub-task).`);
1603
+ }
1604
+ return parent.depth + 1;
1605
+ }
1606
+ /** Vrai si `taskId` est `rootId` ou l'une de ses sous-tâches. */
1607
+ isInSubtree(taskId, rootId) {
1608
+ const seen = new Set();
1609
+ let current = taskId;
1610
+ while (current !== null) {
1611
+ if (current === rootId)
1612
+ return true;
1613
+ if (seen.has(current))
1614
+ return true;
1615
+ seen.add(current);
1616
+ const row = this.db.prepare("SELECT parent_id FROM tasks WHERE id = ?").get(current);
1617
+ current = row?.parent_id ?? null;
1618
+ }
1619
+ return false;
1620
+ }
1621
+ /** Nombre de niveaux de sous-tâches sous `taskId` (0 sans sous-tâche). */
1622
+ subtreeHeight(taskId) {
1623
+ const children = this.store.listChildren(taskId);
1624
+ return children.length === 0 ? 0 : 1 + Math.max(...children.map((child) => this.subtreeHeight(child.id)));
1625
+ }
1626
+ updateDescendantDepths(parentId, parentDepth) {
1627
+ for (const child of this.store.listChildren(parentId)) {
1628
+ this.db.prepare("UPDATE tasks SET depth = ?, updated_at = ? WHERE id = ?").run(parentDepth + 1, this.now, child.id);
1629
+ this.touch("task", child.id, child.spaceId, null);
1630
+ this.updateDescendantDepths(child.id, parentDepth + 1);
1631
+ }
1632
+ }
1633
+ /** Position après la dernière tâche de la colonne (espace, parent, statut), en ignorant `excludeId`. */
1634
+ endOfColumn(spaceId, parentId, status, excludeId) {
1635
+ const row = this.db
1636
+ .prepare("SELECT MAX(position) AS max FROM tasks WHERE space_id = ? AND parent_id IS ? AND status = ? AND id IS NOT ?")
1637
+ .get(spaceId, parentId, status, excludeId);
1638
+ return row.max === null ? 1 : Math.floor(row.max) + 1;
1639
+ }
1640
+ // --- Idées ---
1641
+ createIdea(input) {
1642
+ this.store.getSpace(input.spaceId);
1643
+ const idea = validate(IdeaSchema, {
1644
+ id: newId(),
1645
+ spaceId: input.spaceId,
1646
+ authorId: this.actingUserId,
1647
+ text: input.text,
1648
+ origin: input.origin ?? (this.actor.type === "agent" ? "agent" : "manual"),
1649
+ requestId: this.request?.id ?? null,
1650
+ promotedTaskId: null,
1651
+ archivedAt: null,
1652
+ createdAt: this.now,
1653
+ }, "idea");
1654
+ this.db
1655
+ .prepare(`INSERT INTO ideas (id, space_id, author_id, text, origin, request_id, promoted_task_id, archived_at, created_at)
1656
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1657
+ .run(idea.id, idea.spaceId, idea.authorId, idea.text, idea.origin, idea.requestId, idea.promotedTaskId, idea.archivedAt, idea.createdAt);
1658
+ this.touch("idea", idea.id, idea.spaceId, null);
1659
+ return idea;
1660
+ }
1661
+ updateIdea(id, patch) {
1662
+ const current = this.store.getIdea(id);
1663
+ const next = { ...current };
1664
+ if (patch.text !== undefined)
1665
+ next.text = patch.text;
1666
+ if (patch.promotedTaskId !== undefined) {
1667
+ if (patch.promotedTaskId !== null && this.store.getTask(patch.promotedTaskId).spaceId !== current.spaceId) {
1668
+ throw new InvariantError("An idea can only be promoted to a task of its own space.");
1669
+ }
1670
+ next.promotedTaskId = patch.promotedTaskId;
1671
+ }
1672
+ if (patch.archived !== undefined)
1673
+ next.archivedAt = patch.archived ? (current.archivedAt ?? this.now) : null;
1674
+ const idea = validate(IdeaSchema, next, "idea");
1675
+ this.db
1676
+ .prepare("UPDATE ideas SET text = ?, promoted_task_id = ?, archived_at = ? WHERE id = ?")
1677
+ .run(idea.text, idea.promotedTaskId, idea.archivedAt, id);
1678
+ this.touch("idea", id, idea.spaceId, null);
1679
+ return idea;
1680
+ }
1681
+ /** Supprime une idée ; sa version de suppression a un instantané nul. */
1682
+ deleteIdea(id) {
1683
+ const idea = this.store.getIdea(id);
1684
+ this.db.prepare("DELETE FROM ideas WHERE id = ?").run(id);
1685
+ this.touch("idea", id, idea.spaceId, null);
1686
+ }
1687
+ // --- Demandes ---
1688
+ /** Dépose une demande `pending` de l'utilisateur acteur. */
1689
+ createAgentRequest(input) {
1690
+ if (this.actor.type !== "user")
1691
+ throw new InvariantError("Only a user can create a request for the agent.");
1692
+ const request = validate(AgentRequestSchema, {
1693
+ id: newId(),
1694
+ authorId: this.actor.userId,
1695
+ targetSpaceId: input.targetSpaceId,
1696
+ kind: input.kind,
1697
+ contextTaskId: input.contextTaskId ?? null,
1698
+ contextIdeaId: input.contextIdeaId ?? null,
1699
+ text: input.text,
1700
+ status: "pending",
1701
+ reply: null,
1702
+ failureReason: null,
1703
+ createdAt: this.now,
1704
+ updatedAt: this.now,
1705
+ claimedAt: null,
1706
+ claimedByTokenId: null,
1707
+ completedAt: null,
1708
+ cancelledBy: null,
1709
+ }, "request");
1710
+ this.store.getSpace(request.targetSpaceId);
1711
+ this.db
1712
+ .prepare(`INSERT INTO agent_requests (id, author_id, target_space_id, kind, context_task_id, context_idea_id,
1713
+ text, status, created_at, updated_at)
1714
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1715
+ .run(request.id, request.authorId, request.targetSpaceId, request.kind, request.contextTaskId, request.contextIdeaId, request.text, request.status, request.createdAt, request.updatedAt);
1716
+ this.createdRequestIds.add(request.id);
1717
+ this.pendingRequestIds.push(request.id);
1718
+ this.touchRequest(request);
1719
+ return request;
1720
+ }
1721
+ /** Note la modification d'une demande, pour son espace. */
1722
+ touchRequest(request) {
1723
+ this.touch("agent_request", request.id, request.targetSpaceId, request.authorId, request.contextTaskId);
1724
+ }
1725
+ /** Demande `pending` qu'un membre de son espace modifie ou annule. */
1726
+ pendingRequestForUser(id, action) {
1727
+ if (this.actor.type !== "user")
1728
+ throw new InvariantError(`Only a user can ${action} a request.`);
1729
+ const request = this.store.getAgentRequest(id);
1730
+ if (!this.store.isMember(request.targetSpaceId, this.actor.userId)) {
1731
+ throw new ForbiddenError(`Only members of the space where the request was written can ${action} it.`);
1732
+ }
1733
+ if (request.status !== "pending") {
1734
+ throw new InvariantError(`Request ${id} is ${request.status}: only a pending request can be ${action === "edit" ? "edited" : "cancelled"}.`);
1735
+ }
1736
+ return request;
1737
+ }
1738
+ /** Remplace le texte d'une demande en attente. */
1739
+ updateAgentRequestText(id, text) {
1740
+ const current = this.pendingRequestForUser(id, "edit");
1741
+ const request = validate(AgentRequestSchema, { ...current, text, updatedAt: this.now }, "request");
1742
+ this.db.prepare("UPDATE agent_requests SET text = ?, updated_at = ? WHERE id = ?").run(request.text, request.updatedAt, id);
1743
+ this.touchRequest(request);
1744
+ return request;
1745
+ }
1746
+ /** Annule une demande en attente : l'agent ne la traitera pas. */
1747
+ cancelAgentRequest(id) {
1748
+ const current = this.pendingRequestForUser(id, "cancel");
1749
+ const request = validate(AgentRequestSchema, { ...current, status: "cancelled", cancelledBy: this.actingUserId, updatedAt: this.now }, "request");
1750
+ this.db.prepare("UPDATE agent_requests SET status = 'cancelled', cancelled_by = ?, updated_at = ? WHERE id = ?").run(request.cancelledBy, this.now, id);
1751
+ this.touchRequest(request);
1752
+ return request;
1753
+ }
1754
+ /** Repasse en attente une demande `processing` : un agent pourra la reprendre. */
1755
+ releaseAgentRequest(id) {
1756
+ if (this.actor.type !== "user")
1757
+ throw new InvariantError("Only a user can release a request.");
1758
+ const current = this.store.getAgentRequest(id);
1759
+ if (current.status !== "processing")
1760
+ throw new InvariantError(`Request ${id} is ${current.status}: only a processing request can be released.`);
1761
+ this.db.prepare("UPDATE agent_requests SET status = 'pending', claimed_at = NULL, claimed_by_token_id = NULL, updated_at = ? WHERE id = ?").run(this.now, id);
1762
+ const released = this.store.getAgentRequest(id);
1763
+ this.pendingRequestIds.push(id);
1764
+ this.touchRequest(released);
1765
+ return released;
1766
+ }
1767
+ /** Demande et jeton de l'agent acteur. */
1768
+ agentRequest() {
1769
+ if (!this.request || !this.token)
1770
+ throw new InvariantError("Only the agent can claim, complete or fail a request, or record an agent action.");
1771
+ return { request: this.request, token: this.token };
1772
+ }
1773
+ /**
1774
+ * L'agent prend sa demande avec son jeton : `pending` devient `processing`
1775
+ * et la demande est réservée à ce jeton. La mise à jour conditionnelle, dans
1776
+ * la transaction exclusive, garantit qu'un seul agent prend une demande donnée.
1777
+ */
1778
+ claimAgentRequest() {
1779
+ const { request, token } = this.agentRequest();
1780
+ this.assertRequestInTokenSpace(request, token);
1781
+ const result = this.db
1782
+ .prepare("UPDATE agent_requests SET status = 'processing', claimed_at = ?, claimed_by_token_id = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
1783
+ .run(this.now, token.id, this.now, request.id);
1784
+ if (result.changes !== 1) {
1785
+ throw new InvariantError(`Request ${request.id} is ${request.status}: only a pending request can be claimed. Call list_requests to find pending requests.`);
1786
+ }
1787
+ const claimed = this.store.getAgentRequest(request.id);
1788
+ this.touchRequest(claimed);
1789
+ return claimed;
1790
+ }
1791
+ /** Termine la demande de l'agent avec son message de compte rendu. */
1792
+ completeAgentRequest(reply) {
1793
+ return this.finishAgentRequest("done", reply, "reply");
1794
+ }
1795
+ /** Termine la demande de l'agent en échec, avec la raison montrée à son auteur. */
1796
+ failAgentRequest(reason) {
1797
+ return this.finishAgentRequest("failed", reason, "reason");
1798
+ }
1799
+ finishAgentRequest(status, message, what) {
1800
+ const { request, token } = this.agentRequest();
1801
+ this.assertRequestInTokenSpace(request, token);
1802
+ if (request.status !== "processing") {
1803
+ throw new InvariantError(`Request ${request.id} is ${request.status}, not processing: only a claimed request can be completed or failed.`);
1804
+ }
1805
+ this.assertClaimedByToken(request, token);
1806
+ const text = message.trim();
1807
+ if (text === "")
1808
+ throw new OrganizerError(`The ${what} is empty: write a short message for the author of the request.`);
1809
+ const column = status === "done" ? "reply" : "failure_reason";
1810
+ this.db
1811
+ .prepare(`UPDATE agent_requests SET status = ?, ${column} = ?, completed_at = ?, updated_at = ? WHERE id = ?`)
1812
+ .run(status, text, this.now, this.now, request.id);
1813
+ const finished = this.store.getAgentRequest(request.id);
1814
+ this.touchRequest(finished);
1815
+ return finished;
1816
+ }
1817
+ // --- Actions de l'agent ---
1818
+ /** Enregistre une action de l'agent dans le lot de sa demande, avec le rang suivant. */
1819
+ recordAgentAction(input) {
1820
+ const { request, token } = this.agentRequest();
1821
+ const next = this.db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM agent_actions WHERE request_id = ?").get(request.id);
1822
+ const action = validate(AgentActionSchema, {
1823
+ id: newId(),
1824
+ requestId: request.id,
1825
+ seq: next.seq,
1826
+ entityType: input.entityType,
1827
+ entityId: input.entityId,
1828
+ operation: input.operation,
1829
+ before: input.before ?? null,
1830
+ after: input.after ?? null,
1831
+ summary: input.summary,
1832
+ createdAt: this.now,
1833
+ undoneAt: null,
1834
+ undoneBy: null,
1835
+ tokenId: token.id,
1836
+ }, "agent action");
1837
+ this.db
1838
+ .prepare(`INSERT INTO agent_actions (id, request_id, seq, entity_type, entity_id, operation, before, after, summary, created_at, token_id)
1839
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1840
+ .run(action.id, action.requestId, action.seq, action.entityType, action.entityId, action.operation, action.before === null ? null : JSON.stringify(action.before), action.after === null ? null : JSON.stringify(action.after), action.summary, action.createdAt, action.tokenId);
1841
+ this.touch("agent_action", action.id, request.targetSpaceId, null, input.taskId);
1842
+ return action;
1843
+ }
1844
+ /** Marque annulée une action de l'agent, par l'utilisateur acteur. */
1845
+ markAgentActionUndone(id, taskId) {
1846
+ if (this.actor.type !== "user")
1847
+ throw new InvariantError("Only a user can undo an agent action.");
1848
+ const action = this.store.getAgentAction(id);
1849
+ if (action.undoneAt !== null)
1850
+ throw new InvariantError(`Agent action ${id} has already been undone.`);
1851
+ this.db.prepare("UPDATE agent_actions SET undone_at = ?, undone_by = ? WHERE id = ?").run(this.now, this.actor.userId, id);
1852
+ this.touch("agent_action", id, this.store.getAgentRequest(action.requestId).targetSpaceId, null, taskId);
1853
+ return { ...action, undoneAt: this.now, undoneBy: this.actor.userId };
1854
+ }
1855
+ // --- Notifications ---
1856
+ /** Vrai si l'utilisateur est l'auteur humain de la mutation : on ne se notifie pas soi-même. */
1857
+ isActingUser(userId) {
1858
+ return this.actor.type === "user" && this.actor.userId === userId;
1859
+ }
1860
+ /**
1861
+ * Notifie un utilisateur selon ses préférences : la notification apparaît
1862
+ * dans l'application si elle y est activée, et son identifiant est retenu
1863
+ * pour l'envoi de l'e-mail si celui-ci est activé. Rien n'est créé quand les
1864
+ * deux sont désactivés.
1865
+ */
1866
+ notify(type, userId, taskId, commentId) {
1867
+ const settings = this.store.getUserSettings(userId);
1868
+ const inApp = type === "assigned" ? settings.notifyAssignApp : settings.notifyCommentApp;
1869
+ const email = type === "assigned" ? settings.notifyAssignEmail : settings.notifyCommentEmail;
1870
+ if (!inApp && !email)
1871
+ return;
1872
+ const notification = validate(NotificationSchema, {
1873
+ id: newId(),
1874
+ userId,
1875
+ type,
1876
+ taskId,
1877
+ actorType: this.actor.type,
1878
+ actorId: this.actor.type === "user" ? this.actor.userId : null,
1879
+ requestId: this.request?.id ?? null,
1880
+ commentId,
1881
+ inApp,
1882
+ createdAt: this.now,
1883
+ readAt: null,
1884
+ emailedAt: null,
1885
+ emailError: null,
1886
+ }, "notification");
1887
+ this.db
1888
+ .prepare(`INSERT INTO notifications (id, user_id, type, task_id, actor_type, actor_id, request_id, comment_id, in_app, created_at)
1889
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1890
+ .run(notification.id, notification.userId, notification.type, notification.taskId, notification.actorType, notification.actorId, notification.requestId, notification.commentId, inApp ? 1 : 0, notification.createdAt);
1891
+ if (inApp)
1892
+ this.touch("notification", notification.id, null, userId, taskId);
1893
+ if (email)
1894
+ this.emailNotificationIds.push(notification.id);
1895
+ }
1896
+ /** Marque lue une notification de l'utilisateur acteur. */
1897
+ markNotificationRead(id) {
1898
+ const notification = this.store.getNotification(id);
1899
+ if (notification.userId !== this.actingUserId)
1900
+ throw new ForbiddenError("Only the recipient of a notification can mark it as read.");
1901
+ if (notification.readAt !== null)
1902
+ return;
1903
+ this.db.prepare("UPDATE notifications SET read_at = ? WHERE id = ?").run(this.now, id);
1904
+ this.touch("notification", id, null, notification.userId, notification.taskId);
1905
+ }
1906
+ /** Marque lues toutes les notifications de l'utilisateur acteur ; renvoie leur nombre. */
1907
+ markAllNotificationsRead() {
1908
+ const userId = this.actingUserId;
1909
+ const rows = this.db.prepare("SELECT id, task_id FROM notifications WHERE user_id = ? AND in_app = 1 AND read_at IS NULL").all(userId);
1910
+ const update = this.db.prepare("UPDATE notifications SET read_at = ? WHERE id = ?");
1911
+ for (const row of rows) {
1912
+ update.run(this.now, row.id);
1913
+ this.touch("notification", row.id, null, userId, row.task_id);
1914
+ }
1915
+ return rows.length;
1916
+ }
1917
+ /** Enregistre l'issue de l'envoi de l'e-mail d'une notification : date d'envoi ou raison de l'échec. */
1918
+ recordNotificationEmail(id, outcome) {
1919
+ const notification = this.store.getNotification(id);
1920
+ const recorded = validate(NotificationSchema, { ...notification, emailedAt: outcome.sent ? this.now : null, emailError: outcome.sent ? null : outcome.reason }, "notification");
1921
+ this.db.prepare("UPDATE notifications SET emailed_at = ?, email_error = ? WHERE id = ?").run(recorded.emailedAt, recorded.emailError, id);
1922
+ if (notification.inApp)
1923
+ this.touch("notification", id, null, notification.userId, notification.taskId);
1924
+ return recorded;
1925
+ }
1926
+ /** Modifie les préférences d'un utilisateur. */
1927
+ updateUserSettings(userId, patch) {
1928
+ const settings = validate(UserSettingsSchema, { ...this.store.getUserSettings(userId), ...definedFields(patch) }, "settings");
1929
+ this.db
1930
+ .prepare(`UPDATE user_settings SET notify_assign_app = ?, notify_assign_email = ?, notify_comment_app = ?, notify_comment_email = ?
1931
+ WHERE user_id = ?`)
1932
+ .run(settings.notifyAssignApp ? 1 : 0, settings.notifyAssignEmail ? 1 : 0, settings.notifyCommentApp ? 1 : 0, settings.notifyCommentEmail ? 1 : 0, userId);
1933
+ this.touch("user_settings", userId, null, userId);
1934
+ return settings;
1935
+ }
1936
+ // --- Fin de mutation ---
1937
+ /** Vérifie les invariants sur l'état final, puis écrit les versions et le journal. */
1938
+ finish() {
1939
+ this.checkInvariants();
1940
+ this.writeVersions();
1941
+ this.writeChanges();
1942
+ }
1943
+ checkInvariants() {
1944
+ if (this.actor.type === "user" && !this.store.findUser(this.actor.userId)) {
1945
+ throw new InvariantError(`Acting user ${this.actor.userId} does not exist.`);
1946
+ }
1947
+ if (this.request)
1948
+ this.checkAgentScope(this.request);
1949
+ const spaceIds = new Set();
1950
+ for (const entry of this.touched.values())
1951
+ if (entry.spaceId !== null)
1952
+ spaceIds.add(entry.spaceId);
1953
+ for (const spaceId of spaceIds) {
1954
+ const space = this.store.findSpace(spaceId);
1955
+ if (space)
1956
+ this.checkSpace(space);
1957
+ }
1958
+ for (const entry of this.touched.values()) {
1959
+ if (entry.entityType === "task") {
1960
+ const task = this.store.findTask(entry.entityId);
1961
+ if (task)
1962
+ this.checkTask(task);
1963
+ }
1964
+ else if (entry.entityType === "agent_request" && this.createdRequestIds.has(entry.entityId)) {
1965
+ const request = this.store.findAgentRequest(entry.entityId);
1966
+ if (request)
1967
+ this.checkRequestTarget(request);
1968
+ }
1969
+ }
1970
+ }
1971
+ /**
1972
+ * L'agent ne touche que sa demande et des entités de l'espace cible de
1973
+ * celle-ci ; les notifications qu'il crée visent des personnes, pas un espace.
1974
+ */
1975
+ checkAgentScope(request) {
1976
+ for (const entry of this.touched.values()) {
1977
+ if (entry.entityType === "notification")
1978
+ continue;
1979
+ if (entry.entityType === "agent_request" && entry.entityId === request.id)
1980
+ continue;
1981
+ if (entry.spaceId !== request.targetSpaceId) {
1982
+ throw new InvariantError(`Request ${request.id} targets space ${request.targetSpaceId}; the agent cannot modify ${entry.entityType} ${entry.entityId} outside of it.`);
1983
+ }
1984
+ }
1985
+ }
1986
+ /**
1987
+ * Un espace personnel a exactement un membre, son propriétaire, et aucune
1988
+ * invitation ; un espace commun a exactement un `owner`, son propriétaire.
1989
+ * Les assignés (et testeurs) de ses tâches sont membres de l'espace ; les
1990
+ * résultats et reviews d'un ancien testeur restent en historique.
1991
+ */
1992
+ checkSpace(space) {
1993
+ const members = this.store.listMemberships(space.id);
1994
+ if (space.kind === "personal") {
1995
+ if (members.length !== 1 || members[0].userId !== space.ownerId || members[0].role !== "owner") {
1996
+ throw new InvariantError(`Personal space "${space.name}" must have exactly one member, its owner.`);
1997
+ }
1998
+ const invitations = this.db.prepare("SELECT COUNT(*) AS n FROM invitations WHERE space_id = ?").get(space.id);
1999
+ if (invitations.n > 0)
2000
+ throw new InvariantError(`Nobody can be invited to personal space "${space.name}".`);
2001
+ }
2002
+ else {
2003
+ const owners = members.filter((member) => member.role === "owner");
2004
+ if (owners.length !== 1) {
2005
+ throw new InvariantError(`Shared space "${space.name}" must have exactly one owner (found ${owners.length}).`);
2006
+ }
2007
+ if (owners[0].userId !== space.ownerId) {
2008
+ throw new InvariantError(`The owner member of shared space "${space.name}" must be its owner ${space.ownerId}.`);
2009
+ }
2010
+ }
2011
+ const outsiders = this.db
2012
+ .prepare(`SELECT t.number AS number, x.user_id AS user_id FROM task_assignees x JOIN tasks t ON t.id = x.task_id
2013
+ WHERE t.space_id = ? AND NOT EXISTS (SELECT 1 FROM memberships m WHERE m.space_id = t.space_id AND m.user_id = x.user_id)
2014
+ ORDER BY t.number LIMIT 1`)
2015
+ .get(space.id);
2016
+ if (outsiders) {
2017
+ throw new InvariantError(`User ${outsiders.user_id} is assigned to ${space.keyPrefix}-${outsiders.number} but is not a member of space "${space.name}". Unassign them first.`);
2018
+ }
2019
+ }
2020
+ /**
2021
+ * Profondeur, parent dans le même espace, absence de cycle, position unique
2022
+ * dans la colonne ; tâche testée portée par une tâche de test, dans le même
2023
+ * espace, autre qu'elle-même.
2024
+ */
2025
+ checkTask(task) {
2026
+ const label = taskLabel(this.db, task);
2027
+ if (task.testedTaskId !== null) {
2028
+ if (task.type !== "test")
2029
+ throw new InvariantError(`Task ${label} is a standard task: only a test task can point to the task it tests.`);
2030
+ if (task.testedTaskId === task.id)
2031
+ throw new InvariantError(`Test ${label} cannot test itself.`);
2032
+ const tested = this.store.findTask(task.testedTaskId);
2033
+ if (!tested)
2034
+ throw new InvariantError(`The task tested by ${label} does not exist.`);
2035
+ if (tested.spaceId !== task.spaceId)
2036
+ throw new InvariantError(`Test ${label} can only test a task of its own space.`);
2037
+ }
2038
+ if (task.parentId === null) {
2039
+ if (task.depth !== 0)
2040
+ throw new InvariantError(`Task ${label} has no parent but depth ${task.depth}.`);
2041
+ }
2042
+ else {
2043
+ const parent = this.store.findTask(task.parentId);
2044
+ if (!parent)
2045
+ throw new InvariantError(`Parent of task ${label} does not exist.`);
2046
+ if (parent.spaceId !== task.spaceId)
2047
+ throw new InvariantError(`Task ${label} and its parent belong to different spaces.`);
2048
+ if (task.depth !== parent.depth + 1 || task.depth > MAX_TASK_DEPTH) {
2049
+ throw new InvariantError(`Task ${label} is at depth ${task.depth} under a parent at depth ${parent.depth} (maximum depth ${MAX_TASK_DEPTH}).`);
2050
+ }
2051
+ if (this.isInSubtree(task.parentId, task.id))
2052
+ throw new InvariantError(`Task ${label} is its own ancestor.`);
2053
+ }
2054
+ const clash = this.db
2055
+ .prepare("SELECT number FROM tasks WHERE space_id = ? AND parent_id IS ? AND status = ? AND position = ? AND id <> ? LIMIT 1")
2056
+ .get(task.spaceId, task.parentId, task.status, task.position, task.id);
2057
+ if (clash) {
2058
+ throw new InvariantError(`Task ${label} has the same position (${task.position}) as task ${keyPrefixOf(this.db, task.spaceId)}-${clash.number} in its column. Choose a free position.`);
2059
+ }
2060
+ }
2061
+ /**
2062
+ * Une demande vise l'espace où elle est écrite, dont son auteur est membre.
2063
+ * Une promotion d'idée vise l'espace de son idée, qui n'a pas d'autre
2064
+ * promotion ouverte.
2065
+ */
2066
+ checkRequestTarget(request) {
2067
+ if (!this.store.isMember(request.targetSpaceId, request.authorId)) {
2068
+ throw new InvariantError("A request can only be created from a space its author is a member of.");
2069
+ }
2070
+ if (request.kind === "idea_promotion") {
2071
+ const idea = request.contextIdeaId === null ? null : this.store.findIdea(request.contextIdeaId);
2072
+ if (!idea || idea.spaceId !== request.targetSpaceId)
2073
+ throw new InvariantError("An idea promotion request targets the space of its context idea.");
2074
+ const open = this.store.listOpenPromotionRequests(idea.spaceId).filter((other) => other.contextIdeaId === idea.id);
2075
+ if (open.length > 1)
2076
+ throw new InvariantError("The promotion of this idea is already pending with the agent.");
2077
+ }
2078
+ }
2079
+ /** Une version par entité versionnée modifiée ; une suppression a un instantané nul. */
2080
+ writeVersions() {
2081
+ const actorType = this.actor.type;
2082
+ const actorId = this.actingUserId;
2083
+ const requestId = this.request?.id ?? null;
2084
+ const tokenId = this.token?.id ?? null;
2085
+ const insert = this.db.prepare(`INSERT INTO entity_versions (entity_type, entity_id, version, snapshot, actor_type, actor_id, request_id, created_at, token_id)
2086
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2087
+ for (const entry of this.touched.values()) {
2088
+ if (!VERSIONED.has(entry.entityType))
2089
+ continue;
2090
+ const previous = this.db
2091
+ .prepare("SELECT MAX(version) AS v FROM entity_versions WHERE entity_type = ? AND entity_id = ?")
2092
+ .get(entry.entityType, entry.entityId);
2093
+ const version = (previous.v ?? 0) + 1;
2094
+ let snapshot = null;
2095
+ if (entry.entityType === "task" && this.store.findTask(entry.entityId)) {
2096
+ this.db.prepare("UPDATE tasks SET version = ? WHERE id = ?").run(version, entry.entityId);
2097
+ snapshot = this.store.taskSnapshot(entry.entityId);
2098
+ }
2099
+ else if (entry.entityType === "idea") {
2100
+ snapshot = this.store.findIdea(entry.entityId);
2101
+ }
2102
+ insert.run(entry.entityType, entry.entityId, version, snapshot === null ? null : JSON.stringify(snapshot), actorType, actorId, requestId, this.now, tokenId);
2103
+ }
2104
+ }
2105
+ /** Une ligne du journal par entité modifiée, avec l'auteur de la mutation. */
2106
+ writeChanges() {
2107
+ const insert = this.db.prepare(`INSERT INTO changes (space_id, user_id, entity_type, entity_id, task_id, at, actor_type, actor_id, request_id)
2108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2109
+ const actorId = this.actingUserId;
2110
+ const requestId = this.request?.id ?? null;
2111
+ for (const entry of this.touched.values()) {
2112
+ insert.run(entry.spaceId, entry.userId, entry.entityType, entry.entityId, entry.taskId, this.now, this.actor.type, actorId, requestId);
2113
+ }
2114
+ }
2115
+ }
2116
+ /** Champs de `patch` dont la valeur n'est pas `undefined`. */
2117
+ function definedFields(patch) {
2118
+ return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
2119
+ }