@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.
- package/README.md +548 -3
- package/dist/shared/logic/first-account.d.ts +13 -0
- package/dist/shared/logic/first-account.js +15 -0
- package/dist/shared/logic/labels.d.ts +5 -0
- package/dist/shared/logic/labels.js +17 -0
- package/dist/shared/logic/positions.d.ts +9 -0
- package/dist/shared/logic/positions.js +19 -0
- package/dist/shared/logic/spaces.d.ts +13 -0
- package/dist/shared/logic/spaces.js +48 -0
- package/dist/shared/logic/test-summary.d.ts +28 -0
- package/dist/shared/logic/test-summary.js +35 -0
- package/dist/shared/schemas/agent-api.schema.d.ts +269 -0
- package/dist/shared/schemas/agent-api.schema.js +97 -0
- package/dist/shared/schemas/agent-tools.schema.d.ts +818 -0
- package/dist/shared/schemas/agent-tools.schema.js +198 -0
- package/dist/shared/schemas/api.schema.d.ts +249 -0
- package/dist/shared/schemas/api.schema.js +116 -0
- package/dist/shared/schemas/entities.schema.d.ts +472 -0
- package/dist/shared/schemas/entities.schema.js +262 -0
- package/dist/shared/schemas/enums.schema.d.ts +135 -0
- package/dist/shared/schemas/enums.schema.js +58 -0
- package/dist/shared/schemas/gui-api.schema.d.ts +53 -0
- package/dist/shared/schemas/gui-api.schema.js +20 -0
- package/dist/shared/schemas/ideas-api.schema.d.ts +81 -0
- package/dist/shared/schemas/ideas-api.schema.js +40 -0
- package/dist/shared/schemas/notifications-api.schema.d.ts +87 -0
- package/dist/shared/schemas/notifications-api.schema.js +58 -0
- package/dist/shared/schemas/tasks-api.schema.d.ts +675 -0
- package/dist/shared/schemas/tasks-api.schema.js +225 -0
- package/dist/shared/schemas/tokens-api.schema.d.ts +106 -0
- package/dist/shared/schemas/tokens-api.schema.js +42 -0
- package/dist/shared/schemas/transcription-api.schema.d.ts +15 -0
- package/dist/shared/schemas/transcription-api.schema.js +12 -0
- package/dist/src/agent-api/checklist.d.ts +3 -0
- package/dist/src/agent-api/checklist.js +127 -0
- package/dist/src/agent-api/ideas.d.ts +3 -0
- package/dist/src/agent-api/ideas.js +93 -0
- package/dist/src/agent-api/notifications.d.ts +16 -0
- package/dist/src/agent-api/notifications.js +91 -0
- package/dist/src/agent-api/requests.d.ts +3 -0
- package/dist/src/agent-api/requests.js +60 -0
- package/dist/src/agent-api/router.d.ts +3 -0
- package/dist/src/agent-api/router.js +41 -0
- package/dist/src/agent-api/tasks.d.ts +2 -0
- package/dist/src/agent-api/tasks.js +290 -0
- package/dist/src/agent-api/types.d.ts +41 -0
- package/dist/src/agent-api/types.js +14 -0
- package/dist/src/cli/backup.d.ts +12 -0
- package/dist/src/cli/backup.js +126 -0
- package/dist/src/cli/prompt.d.ts +7 -0
- package/dist/src/cli/prompt.js +40 -0
- package/dist/src/cli/set-user.d.ts +2 -0
- package/dist/src/cli/set-user.js +114 -0
- package/dist/src/cli/setup-mcp.d.ts +10 -0
- package/dist/src/cli/setup-mcp.js +110 -0
- package/dist/src/cli/setup.d.ts +39 -0
- package/dist/src/cli/setup.js +626 -0
- package/dist/src/cli/watch.d.ts +4 -0
- package/dist/src/cli/watch.js +62 -0
- package/dist/src/core/accounts.d.ts +129 -0
- package/dist/src/core/accounts.js +284 -0
- package/dist/src/core/agent-tokens.d.ts +31 -0
- package/dist/src/core/agent-tokens.js +120 -0
- package/dist/src/core/agent.d.ts +71 -0
- package/dist/src/core/agent.js +358 -0
- package/dist/src/core/attachments.d.ts +21 -0
- package/dist/src/core/attachments.js +44 -0
- package/dist/src/core/data-dir.d.ts +5 -0
- package/dist/src/core/data-dir.js +11 -0
- package/dist/src/core/db.d.ts +13 -0
- package/dist/src/core/db.js +38 -0
- package/dist/src/core/env-file.d.ts +11 -0
- package/dist/src/core/env-file.js +36 -0
- package/dist/src/core/errors.d.ts +24 -0
- package/dist/src/core/errors.js +42 -0
- package/dist/src/core/history.d.ts +34 -0
- package/dist/src/core/history.js +279 -0
- package/dist/src/core/ideas.d.ts +16 -0
- package/dist/src/core/ideas.js +100 -0
- package/dist/src/core/ids.d.ts +2 -0
- package/dist/src/core/ids.js +5 -0
- package/dist/src/core/log-dir.d.ts +2 -0
- package/dist/src/core/log-dir.js +9 -0
- package/dist/src/core/logger.d.ts +14 -0
- package/dist/src/core/logger.js +37 -0
- package/dist/src/core/mail.d.ts +43 -0
- package/dist/src/core/mail.js +44 -0
- package/dist/src/core/mcp-entry.d.ts +20 -0
- package/dist/src/core/mcp-entry.js +15 -0
- package/dist/src/core/migrations.d.ts +18 -0
- package/dist/src/core/migrations.js +404 -0
- package/dist/src/core/native-sqlite.d.ts +2 -0
- package/dist/src/core/native-sqlite.js +23 -0
- package/dist/src/core/notifications.d.ts +46 -0
- package/dist/src/core/notifications.js +173 -0
- package/dist/src/core/project-root.d.ts +2 -0
- package/dist/src/core/project-root.js +13 -0
- package/dist/src/core/requests.d.ts +50 -0
- package/dist/src/core/requests.js +240 -0
- package/dist/src/core/snapshots.d.ts +32 -0
- package/dist/src/core/snapshots.js +35 -0
- package/dist/src/core/spaces.d.ts +66 -0
- package/dist/src/core/spaces.js +252 -0
- package/dist/src/core/store.d.ts +577 -0
- package/dist/src/core/store.js +2119 -0
- package/dist/src/core/task-history.d.ts +7 -0
- package/dist/src/core/task-history.js +41 -0
- package/dist/src/core/tasks.d.ts +58 -0
- package/dist/src/core/tasks.js +389 -0
- package/dist/src/core/test-tasks.d.ts +19 -0
- package/dist/src/core/test-tasks.js +135 -0
- package/dist/src/core/transcription.d.ts +45 -0
- package/dist/src/core/transcription.js +92 -0
- package/dist/src/core/watchers.d.ts +30 -0
- package/dist/src/core/watchers.js +50 -0
- package/dist/src/deploy/env-render.d.ts +26 -0
- package/dist/src/deploy/env-render.js +98 -0
- package/dist/src/deploy/templates.d.ts +38 -0
- package/dist/src/deploy/templates.js +130 -0
- package/dist/src/gui-api.d.ts +37 -0
- package/dist/src/gui-api.js +383 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +110 -0
- package/dist/src/mcp/client.d.ts +38 -0
- package/dist/src/mcp/client.js +106 -0
- package/dist/src/mcp/notifications.d.ts +13 -0
- package/dist/src/mcp/notifications.js +54 -0
- package/dist/src/mcp/start.d.ts +14 -0
- package/dist/src/mcp/start.js +123 -0
- package/dist/src/mcp/tools.d.ts +46 -0
- package/dist/src/mcp/tools.js +348 -0
- package/dist/src/mcp/watch-handoff.d.ts +35 -0
- package/dist/src/mcp/watch-handoff.js +91 -0
- package/dist/src/server/app.d.ts +25 -0
- package/dist/src/server/app.js +305 -0
- package/dist/src/server/config.d.ts +23 -0
- package/dist/src/server/config.js +75 -0
- package/dist/src/server/limits.d.ts +2 -0
- package/dist/src/server/limits.js +4 -0
- package/dist/src/server/login-limiter.d.ts +36 -0
- package/dist/src/server/login-limiter.js +81 -0
- package/dist/src/server/start.d.ts +5 -0
- package/dist/src/server/start.js +88 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +16 -0
- package/gui/dist/assets/index-BVN0agCc.css +1 -0
- package/gui/dist/assets/index-DI1_kr81.js +70 -0
- package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/gui/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/gui/dist/index.html +13 -0
- package/package.json +66 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
import type { TaskVersionsResponse } from "../../shared/schemas/agent-api.schema.js";
|
|
3
|
+
/** Historique des versions d'une tâche et restauration d'une version, pour les membres de son espace. */
|
|
4
|
+
/** Versions d'une tâche, de la plus récente à la plus ancienne, avec les champs changés par chacune. */
|
|
5
|
+
export declare function listTaskVersions(store: Store, userId: string, taskId: string): TaskVersionsResponse;
|
|
6
|
+
/** Ramène une tâche à l'état d'une de ses versions ; la restauration crée une nouvelle version. */
|
|
7
|
+
export declare function restoreTaskVersion(store: Store, userId: string, taskId: string, version: number): TaskVersionsResponse;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { InvariantError, NotFoundError } from "./errors.js";
|
|
2
|
+
import { changedTaskFields, restoreTaskFields, TASK_FIELD_LABELS } from "./history.js";
|
|
3
|
+
import { requireTask } from "./tasks.js";
|
|
4
|
+
import { TaskSnapshotSchema } from "../../shared/schemas/entities.schema.js";
|
|
5
|
+
/** Historique des versions d'une tâche et restauration d'une version, pour les membres de son espace. */
|
|
6
|
+
/** Versions d'une tâche, de la plus récente à la plus ancienne, avec les champs changés par chacune. */
|
|
7
|
+
export function listTaskVersions(store, userId, taskId) {
|
|
8
|
+
requireTask(store, taskId, userId);
|
|
9
|
+
const versions = store.listVersions("task", taskId);
|
|
10
|
+
let previous = null;
|
|
11
|
+
const views = [];
|
|
12
|
+
versions.forEach((version, index) => {
|
|
13
|
+
const snapshot = version.snapshot === null ? null : TaskSnapshotSchema.parse(version.snapshot);
|
|
14
|
+
const actor = store.findUser(version.actorId);
|
|
15
|
+
const changes = previous === null ? ["Création"] : snapshot === null ? ["Suppression"] : changedTaskFields(previous, snapshot).map((field) => TASK_FIELD_LABELS[field]);
|
|
16
|
+
views.push({
|
|
17
|
+
version: version.version,
|
|
18
|
+
createdAt: version.createdAt,
|
|
19
|
+
actorType: version.actorType,
|
|
20
|
+
actorName: actor ? (actor.displayName ?? actor.email) : "Ancien membre",
|
|
21
|
+
requestId: version.requestId,
|
|
22
|
+
changes,
|
|
23
|
+
current: index === versions.length - 1,
|
|
24
|
+
});
|
|
25
|
+
previous = snapshot;
|
|
26
|
+
});
|
|
27
|
+
return { versions: views.reverse() };
|
|
28
|
+
}
|
|
29
|
+
/** Ramène une tâche à l'état d'une de ses versions ; la restauration crée une nouvelle version. */
|
|
30
|
+
export function restoreTaskVersion(store, userId, taskId, version) {
|
|
31
|
+
store.mutate({ type: "user", userId }, (tx) => {
|
|
32
|
+
requireTask(tx.store, taskId, userId);
|
|
33
|
+
const found = tx.store.findVersion("task", taskId, version);
|
|
34
|
+
if (!found || found.snapshot === null)
|
|
35
|
+
throw new NotFoundError(`Version ${version} introuvable pour cette tâche.`);
|
|
36
|
+
const changed = restoreTaskFields(tx, taskId, TaskSnapshotSchema.parse(found.snapshot));
|
|
37
|
+
if (changed.length === 0)
|
|
38
|
+
throw new InvariantError(`La tâche est déjà dans l'état de la version ${version}.`);
|
|
39
|
+
});
|
|
40
|
+
return listTaskVersions(store, userId, taskId);
|
|
41
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NotFoundError } from "./errors.js";
|
|
2
|
+
import type { AttachmentStorage } from "./attachments.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
import type { Space, Task } from "../../shared/schemas/entities.schema.js";
|
|
5
|
+
import type { TaskStatus } from "../../shared/schemas/enums.schema.js";
|
|
6
|
+
import { type AttachmentView, type BoardResponse, type ChangesResponse, type ChecklistItemView, type CommentView, type CreateTaskRequest, type TaskDetailResponse, type TaskFilters, type TaskView, type UpdateTaskRequest, type UploadAttachmentRequest } from "../../shared/schemas/tasks-api.schema.js";
|
|
7
|
+
import type { ApiFile } from "../../shared/schemas/gui-api.schema.js";
|
|
8
|
+
/**
|
|
9
|
+
* Tâches, checklist, commentaires et pièces jointes, avec les droits de la
|
|
10
|
+
* GUI : tout membre d'un espace lit et modifie ses tâches ; un non-membre
|
|
11
|
+
* reçoit la même réponse qu'une tâche inexistante. Les droits sont vérifiés
|
|
12
|
+
* dans la transaction de l'écriture.
|
|
13
|
+
*/
|
|
14
|
+
/** Nombre maximal de changements renvoyés par un appel de `listChanges`. */
|
|
15
|
+
export declare const CHANGES_PAGE_SIZE = 500;
|
|
16
|
+
/** Tâche d'un espace dont `userId` est membre. */
|
|
17
|
+
export declare function requireTask(store: Store, taskId: string, userId: string): {
|
|
18
|
+
task: Task;
|
|
19
|
+
space: Space;
|
|
20
|
+
};
|
|
21
|
+
export declare function notFound(): NotFoundError;
|
|
22
|
+
/** Arbre des tâches d'un espace, filtré, avec ses membres et ses tags. */
|
|
23
|
+
export declare function getBoard(store: Store, userId: string, spaceId: string, filters?: TaskFilters): BoardResponse;
|
|
24
|
+
/** Détail d'une tâche : champs, parents, sous-tâches, checklist, commentaires, pièces jointes. */
|
|
25
|
+
export declare function getTaskDetail(store: Store, userId: string, taskId: string): TaskDetailResponse;
|
|
26
|
+
/**
|
|
27
|
+
* Changements postérieurs à `since` des espaces de l'utilisateur, et numéro à
|
|
28
|
+
* repasser au prochain appel. Sans `since`, seul ce numéro est renvoyé : c'est
|
|
29
|
+
* la position de départ du polling.
|
|
30
|
+
*/
|
|
31
|
+
export declare function listChanges(store: Store, userId: string, since: number | null): ChangesResponse;
|
|
32
|
+
export declare function createTask(store: Store, userId: string, spaceId: string, input: CreateTaskRequest): TaskView;
|
|
33
|
+
export declare function updateTask(store: Store, userId: string, taskId: string, patch: UpdateTaskRequest): TaskView;
|
|
34
|
+
/** Supprime une tâche, ses sous-tâches et les fichiers de leurs pièces jointes. */
|
|
35
|
+
export declare function deleteTask(store: Store, storage: AttachmentStorage, userId: string, taskId: string): void;
|
|
36
|
+
export declare function moveTask(store: Store, userId: string, taskId: string, status: TaskStatus, afterId: string | null): TaskView;
|
|
37
|
+
export declare function setAssignees(store: Store, userId: string, taskId: string, userIds: string[]): TaskView;
|
|
38
|
+
export declare function setTags(store: Store, userId: string, taskId: string, names: string[]): TaskView;
|
|
39
|
+
export declare function addChecklistItem(store: Store, userId: string, taskId: string, text: string): ChecklistItemView;
|
|
40
|
+
export declare function updateChecklistItem(store: Store, userId: string, itemId: string, patch: {
|
|
41
|
+
text?: string;
|
|
42
|
+
done?: boolean;
|
|
43
|
+
}): ChecklistItemView;
|
|
44
|
+
export declare function deleteChecklistItem(store: Store, userId: string, itemId: string): void;
|
|
45
|
+
export declare function moveChecklistItem(store: Store, userId: string, itemId: string, afterId: string | null): ChecklistItemView;
|
|
46
|
+
export declare function addComment(store: Store, userId: string, taskId: string, body: string): CommentView;
|
|
47
|
+
export declare function updateComment(store: Store, userId: string, commentId: string, body: string): CommentView;
|
|
48
|
+
export declare function deleteComment(store: Store, userId: string, commentId: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Enregistre une pièce jointe : le fichier est écrit sous
|
|
51
|
+
* `<dataDir>/attachments/<id>`, puis la pièce jointe est créée en base, après
|
|
52
|
+
* vérification des droits dans la même transaction. Un refus efface le
|
|
53
|
+
* fichier écrit.
|
|
54
|
+
*/
|
|
55
|
+
export declare function uploadAttachment(store: Store, storage: AttachmentStorage, userId: string, taskId: string, input: UploadAttachmentRequest): AttachmentView;
|
|
56
|
+
/** Contenu d'une pièce jointe, avec son type et son nom. */
|
|
57
|
+
export declare function downloadAttachment(store: Store, storage: AttachmentStorage, userId: string, attachmentId: string): ApiFile;
|
|
58
|
+
export declare function deleteAttachment(store: Store, storage: AttachmentStorage, userId: string, attachmentId: string): void;
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { NotFoundError, OrganizerError } from "./errors.js";
|
|
2
|
+
import { newId } from "./ids.js";
|
|
3
|
+
import { requireMember } from "./spaces.js";
|
|
4
|
+
import { MAX_ATTACHMENT_BYTES, } from "../../shared/schemas/tasks-api.schema.js";
|
|
5
|
+
import { summarizeTest } from "../../shared/logic/test-summary.js";
|
|
6
|
+
import { testDetail } from "./test-tasks.js";
|
|
7
|
+
/**
|
|
8
|
+
* Tâches, checklist, commentaires et pièces jointes, avec les droits de la
|
|
9
|
+
* GUI : tout membre d'un espace lit et modifie ses tâches ; un non-membre
|
|
10
|
+
* reçoit la même réponse qu'une tâche inexistante. Les droits sont vérifiés
|
|
11
|
+
* dans la transaction de l'écriture.
|
|
12
|
+
*/
|
|
13
|
+
/** Nombre maximal de changements renvoyés par un appel de `listChanges`. */
|
|
14
|
+
export const CHANGES_PAGE_SIZE = 500;
|
|
15
|
+
const asUser = (userId) => ({ type: "user", userId });
|
|
16
|
+
// --- Droits ---------------------------------------------------------------------
|
|
17
|
+
/** Tâche d'un espace dont `userId` est membre. */
|
|
18
|
+
export function requireTask(store, taskId, userId) {
|
|
19
|
+
const task = store.findTask(taskId);
|
|
20
|
+
if (!task || !store.isMember(task.spaceId, userId))
|
|
21
|
+
throw notFound();
|
|
22
|
+
return { task, space: store.getSpace(task.spaceId) };
|
|
23
|
+
}
|
|
24
|
+
export function notFound() {
|
|
25
|
+
return new NotFoundError("Tâche introuvable, ou vous n'êtes pas membre de son espace.");
|
|
26
|
+
}
|
|
27
|
+
// --- Vues -----------------------------------------------------------------------
|
|
28
|
+
function keyOf(space, task) {
|
|
29
|
+
return `${space.keyPrefix}-${task.number}`;
|
|
30
|
+
}
|
|
31
|
+
function membersOf(store, spaceId) {
|
|
32
|
+
return store.listMemberships(spaceId).map((membership) => {
|
|
33
|
+
const user = store.getUser(membership.userId);
|
|
34
|
+
return { userId: user.id, displayName: user.displayName, email: user.email };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function tagsOf(store, spaceId) {
|
|
38
|
+
return store.listTags(spaceId).map((tag) => ({ id: tag.id, name: tag.name }));
|
|
39
|
+
}
|
|
40
|
+
const STATUS_ORDER = { todo: 0, doing: 1, review: 2, done: 3 };
|
|
41
|
+
/** Ordre d'affichage : colonne, puis rang dans la colonne. */
|
|
42
|
+
function byColumn(a, b) {
|
|
43
|
+
return STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.position - b.position;
|
|
44
|
+
}
|
|
45
|
+
/** Arbre des tâches d'un espace : nœuds de premier niveau, chacun avec ses sous-tâches. */
|
|
46
|
+
function buildForest(store, space) {
|
|
47
|
+
const tasks = store.listTasks(space.id);
|
|
48
|
+
const assignees = new Map();
|
|
49
|
+
for (const { taskId, userId } of store.listAssignmentsInSpace(space.id))
|
|
50
|
+
assignees.set(taskId, [...(assignees.get(taskId) ?? []), userId]);
|
|
51
|
+
const tags = new Map();
|
|
52
|
+
for (const { taskId, tagId } of store.listTaskTagsInSpace(space.id))
|
|
53
|
+
tags.set(taskId, [...(tags.get(taskId) ?? []), tagId]);
|
|
54
|
+
const checkpoints = new Map();
|
|
55
|
+
for (const { id, taskId } of store.listCheckpointsInSpace(space.id))
|
|
56
|
+
checkpoints.set(taskId, [...(checkpoints.get(taskId) ?? []), id]);
|
|
57
|
+
const results = new Map();
|
|
58
|
+
for (const result of store.listTestResultsInSpace(space.id))
|
|
59
|
+
results.set(result.taskId, [...(results.get(result.taskId) ?? []), result]);
|
|
60
|
+
const byId = new Map();
|
|
61
|
+
for (const task of tasks) {
|
|
62
|
+
byId.set(task.id, {
|
|
63
|
+
id: task.id,
|
|
64
|
+
key: keyOf(space, task),
|
|
65
|
+
parentId: task.parentId,
|
|
66
|
+
depth: task.depth,
|
|
67
|
+
type: task.type,
|
|
68
|
+
title: task.title,
|
|
69
|
+
status: task.status,
|
|
70
|
+
priority: task.priority,
|
|
71
|
+
position: task.position,
|
|
72
|
+
assigneeIds: assignees.get(task.id) ?? [],
|
|
73
|
+
tagIds: tags.get(task.id) ?? [],
|
|
74
|
+
subtasks: { done: 0, total: 0 },
|
|
75
|
+
test: task.type === "test" ? summarizeTest(checkpoints.get(task.id) ?? [], results.get(task.id) ?? [], assignees.get(task.id) ?? []) : null,
|
|
76
|
+
children: [],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const roots = [];
|
|
80
|
+
for (const node of byId.values()) {
|
|
81
|
+
const parent = node.parentId === null ? null : byId.get(node.parentId);
|
|
82
|
+
if (parent) {
|
|
83
|
+
parent.children.push(node);
|
|
84
|
+
parent.subtasks.total += 1;
|
|
85
|
+
if (node.status === "done")
|
|
86
|
+
parent.subtasks.done += 1;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
roots.push(node);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const node of byId.values())
|
|
93
|
+
node.children.sort(byColumn);
|
|
94
|
+
roots.sort(byColumn);
|
|
95
|
+
return { roots, byId };
|
|
96
|
+
}
|
|
97
|
+
/** Vrai si la tâche répond à tous les filtres. */
|
|
98
|
+
function matches(node, filters) {
|
|
99
|
+
return ((filters.type === undefined || node.type === filters.type) &&
|
|
100
|
+
(filters.priority === undefined || node.priority === filters.priority) &&
|
|
101
|
+
(filters.assignee === undefined || node.assigneeIds.includes(filters.assignee)) &&
|
|
102
|
+
(filters.tag === undefined || node.tagIds.includes(filters.tag)));
|
|
103
|
+
}
|
|
104
|
+
/** Garde les tâches qui répondent aux filtres ou dont une sous-tâche y répond. */
|
|
105
|
+
function filterTree(nodes, filters) {
|
|
106
|
+
const kept = [];
|
|
107
|
+
for (const node of nodes) {
|
|
108
|
+
const children = filterTree(node.children, filters);
|
|
109
|
+
if (matches(node, filters) || children.length > 0)
|
|
110
|
+
kept.push({ ...node, children });
|
|
111
|
+
}
|
|
112
|
+
return kept;
|
|
113
|
+
}
|
|
114
|
+
function taskView(store, space, task) {
|
|
115
|
+
return {
|
|
116
|
+
id: task.id,
|
|
117
|
+
key: keyOf(space, task),
|
|
118
|
+
spaceId: task.spaceId,
|
|
119
|
+
parentId: task.parentId,
|
|
120
|
+
depth: task.depth,
|
|
121
|
+
type: task.type,
|
|
122
|
+
title: task.title,
|
|
123
|
+
description: task.description,
|
|
124
|
+
status: task.status,
|
|
125
|
+
priority: task.priority,
|
|
126
|
+
assigneeIds: store.listAssigneeIds(task.id),
|
|
127
|
+
tagIds: store.listTagIds(task.id),
|
|
128
|
+
testedTaskId: task.testedTaskId,
|
|
129
|
+
createdBy: task.createdBy,
|
|
130
|
+
createdAt: task.createdAt,
|
|
131
|
+
updatedAt: task.updatedAt,
|
|
132
|
+
version: task.version,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function attachmentView(attachment) {
|
|
136
|
+
return {
|
|
137
|
+
id: attachment.id,
|
|
138
|
+
filename: attachment.filename,
|
|
139
|
+
mime: attachment.mime,
|
|
140
|
+
size: attachment.size,
|
|
141
|
+
uploaderId: attachment.uploaderId,
|
|
142
|
+
createdAt: attachment.createdAt,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// --- Lecture --------------------------------------------------------------------
|
|
146
|
+
/** Arbre des tâches d'un espace, filtré, avec ses membres et ses tags. */
|
|
147
|
+
export function getBoard(store, userId, spaceId, filters = {}) {
|
|
148
|
+
const { space } = requireMember(store, spaceId, userId);
|
|
149
|
+
const { roots } = buildForest(store, space);
|
|
150
|
+
return { spaceId: space.id, members: membersOf(store, space.id), tags: tagsOf(store, space.id), tasks: filterTree(roots, filters) };
|
|
151
|
+
}
|
|
152
|
+
/** Détail d'une tâche : champs, parents, sous-tâches, checklist, commentaires, pièces jointes. */
|
|
153
|
+
export function getTaskDetail(store, userId, taskId) {
|
|
154
|
+
const { task, space } = requireTask(store, taskId, userId);
|
|
155
|
+
const { byId } = buildForest(store, space);
|
|
156
|
+
const ancestors = [];
|
|
157
|
+
for (let parentId = task.parentId; parentId !== null;) {
|
|
158
|
+
const parent = store.getTask(parentId);
|
|
159
|
+
ancestors.unshift({ id: parent.id, key: keyOf(space, parent), title: parent.title });
|
|
160
|
+
parentId = parent.parentId;
|
|
161
|
+
}
|
|
162
|
+
const checklist = store.listChecklist(task.id).map(({ id, text, done, position }) => ({ id, text, done, position }));
|
|
163
|
+
const comments = store.listComments(task.id).map((comment) => commentView(store, comment));
|
|
164
|
+
const tested = task.testedTaskId === null ? null : store.findTask(task.testedTaskId);
|
|
165
|
+
return {
|
|
166
|
+
task: taskView(store, space, task),
|
|
167
|
+
ancestors,
|
|
168
|
+
testedTask: tested ? { id: tested.id, key: keyOf(space, tested), title: tested.title } : null,
|
|
169
|
+
children: byId.get(task.id).children,
|
|
170
|
+
checklist,
|
|
171
|
+
test: task.type === "test" ? testDetail(store, task) : null,
|
|
172
|
+
comments,
|
|
173
|
+
attachments: store.listAttachments(task.id).map(attachmentView),
|
|
174
|
+
members: membersOf(store, space.id),
|
|
175
|
+
tags: tagsOf(store, space.id),
|
|
176
|
+
agentEdit: agentEditOf(store, task.id),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Dernière modification de la tâche quand sa dernière version vient de l'agent et que l'action correspondante n'est pas annulée. */
|
|
180
|
+
function agentEditOf(store, taskId) {
|
|
181
|
+
const latest = store.listVersions("task", taskId).at(-1);
|
|
182
|
+
if (!latest || latest.actorType !== "agent" || latest.requestId === null)
|
|
183
|
+
return null;
|
|
184
|
+
const action = store.lastLiveAgentActionOn("task", taskId);
|
|
185
|
+
if (!action || action.requestId !== latest.requestId)
|
|
186
|
+
return null;
|
|
187
|
+
return { actionId: action.id, at: latest.createdAt };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Changements postérieurs à `since` des espaces de l'utilisateur, et numéro à
|
|
191
|
+
* repasser au prochain appel. Sans `since`, seul ce numéro est renvoyé : c'est
|
|
192
|
+
* la position de départ du polling.
|
|
193
|
+
*/
|
|
194
|
+
export function listChanges(store, userId, since) {
|
|
195
|
+
return store.db.transaction(() => {
|
|
196
|
+
const last = store.lastChangeSeq();
|
|
197
|
+
if (since === null)
|
|
198
|
+
return { seq: last, changes: [] };
|
|
199
|
+
const changes = store.listChangesForUser(userId, since, CHANGES_PAGE_SIZE);
|
|
200
|
+
const seq = changes.length === CHANGES_PAGE_SIZE ? changes[changes.length - 1].seq : Math.max(last, since);
|
|
201
|
+
return { seq, changes };
|
|
202
|
+
})();
|
|
203
|
+
}
|
|
204
|
+
// --- Tâches ---------------------------------------------------------------------
|
|
205
|
+
export function createTask(store, userId, spaceId, input) {
|
|
206
|
+
const { space, task } = store.mutate(asUser(userId), (tx) => {
|
|
207
|
+
const { space } = requireMember(tx.store, spaceId, userId);
|
|
208
|
+
if (input.parentId)
|
|
209
|
+
requireTask(tx.store, input.parentId, userId);
|
|
210
|
+
return { space, task: tx.createTask({ spaceId, ...input }) };
|
|
211
|
+
});
|
|
212
|
+
return taskView(store, space, store.getTask(task.id));
|
|
213
|
+
}
|
|
214
|
+
export function updateTask(store, userId, taskId, patch) {
|
|
215
|
+
const space = store.mutate(asUser(userId), (tx) => {
|
|
216
|
+
const { space } = requireTask(tx.store, taskId, userId);
|
|
217
|
+
if (patch.parentId)
|
|
218
|
+
requireTask(tx.store, patch.parentId, userId);
|
|
219
|
+
if (patch.testedTaskId)
|
|
220
|
+
requireTask(tx.store, patch.testedTaskId, userId);
|
|
221
|
+
tx.updateTask(taskId, patch);
|
|
222
|
+
return space;
|
|
223
|
+
});
|
|
224
|
+
return taskView(store, space, store.getTask(taskId));
|
|
225
|
+
}
|
|
226
|
+
/** Supprime une tâche, ses sous-tâches et les fichiers de leurs pièces jointes. */
|
|
227
|
+
export function deleteTask(store, storage, userId, taskId) {
|
|
228
|
+
const removed = store.mutate(asUser(userId), (tx) => {
|
|
229
|
+
requireTask(tx.store, taskId, userId);
|
|
230
|
+
const attachments = tx.store.listAttachmentsInSubtree(taskId);
|
|
231
|
+
tx.deleteTask(taskId);
|
|
232
|
+
return attachments;
|
|
233
|
+
});
|
|
234
|
+
storage.remove(removed.map((attachment) => attachment.storagePath));
|
|
235
|
+
}
|
|
236
|
+
export function moveTask(store, userId, taskId, status, afterId) {
|
|
237
|
+
const space = store.mutate(asUser(userId), (tx) => {
|
|
238
|
+
const { space } = requireTask(tx.store, taskId, userId);
|
|
239
|
+
tx.moveTask(taskId, status, afterId);
|
|
240
|
+
return space;
|
|
241
|
+
});
|
|
242
|
+
return taskView(store, space, store.getTask(taskId));
|
|
243
|
+
}
|
|
244
|
+
export function setAssignees(store, userId, taskId, userIds) {
|
|
245
|
+
const space = store.mutate(asUser(userId), (tx) => {
|
|
246
|
+
const { space } = requireTask(tx.store, taskId, userId);
|
|
247
|
+
for (const assignee of userIds) {
|
|
248
|
+
if (!tx.store.isMember(space.id, assignee))
|
|
249
|
+
throw new OrganizerError(`Seuls les membres de l'espace « ${space.name} » peuvent être assignés.`);
|
|
250
|
+
}
|
|
251
|
+
tx.setAssignees(taskId, userIds);
|
|
252
|
+
return space;
|
|
253
|
+
});
|
|
254
|
+
return taskView(store, space, store.getTask(taskId));
|
|
255
|
+
}
|
|
256
|
+
export function setTags(store, userId, taskId, names) {
|
|
257
|
+
const space = store.mutate(asUser(userId), (tx) => {
|
|
258
|
+
const { space } = requireTask(tx.store, taskId, userId);
|
|
259
|
+
tx.setTags(taskId, names);
|
|
260
|
+
return space;
|
|
261
|
+
});
|
|
262
|
+
return taskView(store, space, store.getTask(taskId));
|
|
263
|
+
}
|
|
264
|
+
// --- Checklist ------------------------------------------------------------------
|
|
265
|
+
/** Tâche qui porte un élément de checklist, pour un membre de son espace. */
|
|
266
|
+
function requireChecklistItem(store, itemId, userId) {
|
|
267
|
+
const item = store.findChecklistItem(itemId);
|
|
268
|
+
if (!item)
|
|
269
|
+
throw notFound();
|
|
270
|
+
requireTask(store, item.taskId, userId);
|
|
271
|
+
return item;
|
|
272
|
+
}
|
|
273
|
+
export function addChecklistItem(store, userId, taskId, text) {
|
|
274
|
+
const { id, done, position } = store.mutate(asUser(userId), (tx) => {
|
|
275
|
+
requireTask(tx.store, taskId, userId);
|
|
276
|
+
return tx.addChecklistItem(taskId, text);
|
|
277
|
+
});
|
|
278
|
+
return { id, text: text.trim(), done, position };
|
|
279
|
+
}
|
|
280
|
+
export function updateChecklistItem(store, userId, itemId, patch) {
|
|
281
|
+
const { id, text, done, position } = store.mutate(asUser(userId), (tx) => {
|
|
282
|
+
requireChecklistItem(tx.store, itemId, userId);
|
|
283
|
+
return tx.updateChecklistItem(itemId, patch);
|
|
284
|
+
});
|
|
285
|
+
return { id, text, done, position };
|
|
286
|
+
}
|
|
287
|
+
export function deleteChecklistItem(store, userId, itemId) {
|
|
288
|
+
store.mutate(asUser(userId), (tx) => {
|
|
289
|
+
requireChecklistItem(tx.store, itemId, userId);
|
|
290
|
+
tx.deleteChecklistItem(itemId);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
export function moveChecklistItem(store, userId, itemId, afterId) {
|
|
294
|
+
const { id, text, done, position } = store.mutate(asUser(userId), (tx) => {
|
|
295
|
+
requireChecklistItem(tx.store, itemId, userId);
|
|
296
|
+
return tx.moveChecklistItem(itemId, afterId);
|
|
297
|
+
});
|
|
298
|
+
return { id, text, done, position };
|
|
299
|
+
}
|
|
300
|
+
// --- Commentaires ---------------------------------------------------------------
|
|
301
|
+
function commentView(store, comment) {
|
|
302
|
+
const author = store.getUser(comment.authorId);
|
|
303
|
+
return {
|
|
304
|
+
id: comment.id,
|
|
305
|
+
authorId: comment.authorId,
|
|
306
|
+
authorName: author.displayName ?? author.email,
|
|
307
|
+
body: comment.body,
|
|
308
|
+
createdAt: comment.createdAt,
|
|
309
|
+
editedAt: comment.editedAt,
|
|
310
|
+
byAgent: comment.requestId !== null,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function requireComment(store, commentId, userId) {
|
|
314
|
+
const comment = store.findComment(commentId);
|
|
315
|
+
if (!comment)
|
|
316
|
+
throw notFound();
|
|
317
|
+
requireTask(store, comment.taskId, userId);
|
|
318
|
+
return comment;
|
|
319
|
+
}
|
|
320
|
+
export function addComment(store, userId, taskId, body) {
|
|
321
|
+
const comment = store.mutate(asUser(userId), (tx) => {
|
|
322
|
+
requireTask(tx.store, taskId, userId);
|
|
323
|
+
return tx.addComment(taskId, body);
|
|
324
|
+
});
|
|
325
|
+
return commentView(store, comment);
|
|
326
|
+
}
|
|
327
|
+
export function updateComment(store, userId, commentId, body) {
|
|
328
|
+
const comment = store.mutate(asUser(userId), (tx) => {
|
|
329
|
+
requireComment(tx.store, commentId, userId);
|
|
330
|
+
return tx.updateComment(commentId, body);
|
|
331
|
+
});
|
|
332
|
+
return commentView(store, comment);
|
|
333
|
+
}
|
|
334
|
+
export function deleteComment(store, userId, commentId) {
|
|
335
|
+
store.mutate(asUser(userId), (tx) => {
|
|
336
|
+
requireComment(tx.store, commentId, userId);
|
|
337
|
+
tx.deleteComment(commentId);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
// --- Pièces jointes -------------------------------------------------------------
|
|
341
|
+
/** Taille lisible d'un nombre d'octets, en Mo. */
|
|
342
|
+
function megabytes(bytes) {
|
|
343
|
+
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} Mo`;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Enregistre une pièce jointe : le fichier est écrit sous
|
|
347
|
+
* `<dataDir>/attachments/<id>`, puis la pièce jointe est créée en base, après
|
|
348
|
+
* vérification des droits dans la même transaction. Un refus efface le
|
|
349
|
+
* fichier écrit.
|
|
350
|
+
*/
|
|
351
|
+
export function uploadAttachment(store, storage, userId, taskId, input) {
|
|
352
|
+
requireTask(store, taskId, userId);
|
|
353
|
+
const content = Buffer.from(input.contentBase64, "base64");
|
|
354
|
+
if (content.length > MAX_ATTACHMENT_BYTES) {
|
|
355
|
+
throw new OrganizerError(`Fichier trop volumineux (${megabytes(content.length)}) : une pièce jointe ne dépasse pas ${megabytes(MAX_ATTACHMENT_BYTES)}.`);
|
|
356
|
+
}
|
|
357
|
+
const id = newId();
|
|
358
|
+
const storagePath = storage.write(id, content);
|
|
359
|
+
try {
|
|
360
|
+
const attachment = store.mutate(asUser(userId), (tx) => {
|
|
361
|
+
requireTask(tx.store, taskId, userId);
|
|
362
|
+
return tx.createAttachment({ id, taskId, filename: input.filename, mime: input.mime, size: content.length, storagePath });
|
|
363
|
+
});
|
|
364
|
+
return attachmentView(attachment);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
storage.remove([storagePath]);
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function requireAttachment(store, attachmentId, userId) {
|
|
372
|
+
const attachment = store.findAttachment(attachmentId);
|
|
373
|
+
if (!attachment)
|
|
374
|
+
throw notFound();
|
|
375
|
+
requireTask(store, attachment.taskId, userId);
|
|
376
|
+
return attachment;
|
|
377
|
+
}
|
|
378
|
+
/** Contenu d'une pièce jointe, avec son type et son nom. */
|
|
379
|
+
export function downloadAttachment(store, storage, userId, attachmentId) {
|
|
380
|
+
const attachment = requireAttachment(store, attachmentId, userId);
|
|
381
|
+
return { contentBase64: storage.read(attachment).toString("base64"), mime: attachment.mime, filename: attachment.filename };
|
|
382
|
+
}
|
|
383
|
+
export function deleteAttachment(store, storage, userId, attachmentId) {
|
|
384
|
+
const removed = store.mutate(asUser(userId), (tx) => {
|
|
385
|
+
requireAttachment(tx.store, attachmentId, userId);
|
|
386
|
+
return tx.deleteAttachment(attachmentId);
|
|
387
|
+
});
|
|
388
|
+
storage.remove([removed.storagePath]);
|
|
389
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
import type { Task } from "../../shared/schemas/entities.schema.js";
|
|
3
|
+
import type { TestResultValue } from "../../shared/schemas/enums.schema.js";
|
|
4
|
+
import type { CheckpointView, ReviewDraftResponse, SubmittedReviewView, TestDetail, TestResultView } from "../../shared/schemas/tasks-api.schema.js";
|
|
5
|
+
/** Points de vérification, bilan, résultats et brouillon de chaque testeur, anciens testeurs, reviews soumises. */
|
|
6
|
+
export declare function testDetail(store: Store, task: Task): TestDetail;
|
|
7
|
+
export declare function addCheckpoint(store: Store, userId: string, taskId: string, text: string): CheckpointView;
|
|
8
|
+
export declare function updateCheckpoint(store: Store, userId: string, checkpointId: string, text: string): CheckpointView;
|
|
9
|
+
export declare function deleteCheckpoint(store: Store, userId: string, checkpointId: string): void;
|
|
10
|
+
export declare function moveCheckpoint(store: Store, userId: string, checkpointId: string, afterId: string | null): CheckpointView;
|
|
11
|
+
/** Résultat de l'utilisateur, testeur de la tâche, sur un point. */
|
|
12
|
+
export declare function setTestResult(store: Store, userId: string, checkpointId: string, patch: {
|
|
13
|
+
result?: TestResultValue;
|
|
14
|
+
comment?: string;
|
|
15
|
+
}): TestResultView;
|
|
16
|
+
/** Enregistre le brouillon de review de l'utilisateur, testeur de la tâche. */
|
|
17
|
+
export declare function saveReviewDraft(store: Store, userId: string, taskId: string, text: string): ReviewDraftResponse;
|
|
18
|
+
/** Soumet la review de l'utilisateur : elle est figée et une demande `test_review` part dans la file de l'agent. */
|
|
19
|
+
export declare function submitReview(store: Store, userId: string, taskId: string, text: string): SubmittedReviewView;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ForbiddenError, InvariantError } from "./errors.js";
|
|
2
|
+
import { notFound, requireTask } from "./tasks.js";
|
|
3
|
+
import { summarizeTest } from "../../shared/logic/test-summary.js";
|
|
4
|
+
/**
|
|
5
|
+
* Tâches de test, avec les droits de la GUI : tout membre de l'espace gère
|
|
6
|
+
* les points de vérification ; seul un testeur (assigné de la tâche) saisit
|
|
7
|
+
* ses propres résultats, son brouillon de review et la soumet. Les résultats
|
|
8
|
+
* et reviews d'un ancien testeur restent affichés, en lecture seule. Les
|
|
9
|
+
* droits sont vérifiés dans la transaction de l'écriture.
|
|
10
|
+
*/
|
|
11
|
+
const asUser = (userId) => ({ type: "user", userId });
|
|
12
|
+
// --- Vues -----------------------------------------------------------------------
|
|
13
|
+
function testerName(store, userId) {
|
|
14
|
+
const user = store.findUser(userId);
|
|
15
|
+
return user ? (user.displayName ?? user.email) : "Ancien membre";
|
|
16
|
+
}
|
|
17
|
+
function submittedReviewView(store, review) {
|
|
18
|
+
const request = review.requestId === null ? null : store.findAgentRequest(review.requestId);
|
|
19
|
+
return {
|
|
20
|
+
id: review.id,
|
|
21
|
+
testerId: review.testerId,
|
|
22
|
+
testerName: testerName(store, review.testerId),
|
|
23
|
+
text: review.text,
|
|
24
|
+
submittedAt: review.submittedAt,
|
|
25
|
+
requestId: review.requestId,
|
|
26
|
+
requestStatus: request?.status ?? null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function resultView({ checkpointId, result, comment, updatedAt }) {
|
|
30
|
+
return { checkpointId, result, comment, updatedAt };
|
|
31
|
+
}
|
|
32
|
+
/** Points de vérification, bilan, résultats et brouillon de chaque testeur, anciens testeurs, reviews soumises. */
|
|
33
|
+
export function testDetail(store, task) {
|
|
34
|
+
const checkpoints = store.listCheckpoints(task.id).map(({ id, text, position }) => ({ id, text, position }));
|
|
35
|
+
const testerIds = store.listAssigneeIds(task.id);
|
|
36
|
+
const results = store.listTestResults(task.id);
|
|
37
|
+
const reviews = store.listTestReviews(task.id);
|
|
38
|
+
const testers = testerIds.map((userId) => ({
|
|
39
|
+
userId,
|
|
40
|
+
results: results.filter((result) => result.testerId === userId).map(resultView),
|
|
41
|
+
draft: reviews.find((review) => review.testerId === userId && review.submittedAt === null)?.text ?? "",
|
|
42
|
+
}));
|
|
43
|
+
const pastIds = [...new Set(results.map((result) => result.testerId))].filter((userId) => !testerIds.includes(userId));
|
|
44
|
+
const pastTesters = pastIds
|
|
45
|
+
.map((userId) => ({ userId, name: testerName(store, userId), results: results.filter((result) => result.testerId === userId).map(resultView) }))
|
|
46
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
47
|
+
return {
|
|
48
|
+
checkpoints,
|
|
49
|
+
summary: summarizeTest(checkpoints.map((checkpoint) => checkpoint.id), results, testerIds),
|
|
50
|
+
testers,
|
|
51
|
+
pastTesters,
|
|
52
|
+
reviews: reviews
|
|
53
|
+
.filter((review) => review.submittedAt !== null)
|
|
54
|
+
.reverse()
|
|
55
|
+
.map((review) => submittedReviewView(store, review)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// --- Droits ---------------------------------------------------------------------
|
|
59
|
+
/** Tâche de test d'un espace dont `userId` est membre. */
|
|
60
|
+
function requireTestTask(store, taskId, userId) {
|
|
61
|
+
const { task } = requireTask(store, taskId, userId);
|
|
62
|
+
if (task.type !== "test")
|
|
63
|
+
throw new InvariantError("Cette tâche n'est pas une tâche de test : elle a une checklist, pas de points de vérification.");
|
|
64
|
+
return task;
|
|
65
|
+
}
|
|
66
|
+
/** Tâche de test dont `userId` est testeur. */
|
|
67
|
+
function requireTester(store, taskId, userId) {
|
|
68
|
+
const task = requireTestTask(store, taskId, userId);
|
|
69
|
+
if (!store.listAssigneeIds(taskId).includes(userId)) {
|
|
70
|
+
throw new ForbiddenError("Seuls les testeurs de la tâche saisissent des résultats et une review : assignez-vous d'abord la tâche.");
|
|
71
|
+
}
|
|
72
|
+
return task;
|
|
73
|
+
}
|
|
74
|
+
/** Point de vérification d'une tâche de test dont `userId` est membre de l'espace. */
|
|
75
|
+
function requireCheckpoint(store, checkpointId, userId) {
|
|
76
|
+
const checkpoint = store.findCheckpoint(checkpointId);
|
|
77
|
+
if (!checkpoint)
|
|
78
|
+
throw notFound();
|
|
79
|
+
requireTestTask(store, checkpoint.taskId, userId);
|
|
80
|
+
return checkpoint;
|
|
81
|
+
}
|
|
82
|
+
// --- Points de vérification -----------------------------------------------------
|
|
83
|
+
export function addCheckpoint(store, userId, taskId, text) {
|
|
84
|
+
const { id, text: saved, position } = store.mutate(asUser(userId), (tx) => {
|
|
85
|
+
requireTestTask(tx.store, taskId, userId);
|
|
86
|
+
return tx.addCheckpoint(taskId, text);
|
|
87
|
+
});
|
|
88
|
+
return { id, text: saved, position };
|
|
89
|
+
}
|
|
90
|
+
export function updateCheckpoint(store, userId, checkpointId, text) {
|
|
91
|
+
const { id, text: saved, position } = store.mutate(asUser(userId), (tx) => {
|
|
92
|
+
requireCheckpoint(tx.store, checkpointId, userId);
|
|
93
|
+
return tx.updateCheckpoint(checkpointId, text);
|
|
94
|
+
});
|
|
95
|
+
return { id, text: saved, position };
|
|
96
|
+
}
|
|
97
|
+
export function deleteCheckpoint(store, userId, checkpointId) {
|
|
98
|
+
store.mutate(asUser(userId), (tx) => {
|
|
99
|
+
requireCheckpoint(tx.store, checkpointId, userId);
|
|
100
|
+
tx.deleteCheckpoint(checkpointId);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function moveCheckpoint(store, userId, checkpointId, afterId) {
|
|
104
|
+
const { id, text, position } = store.mutate(asUser(userId), (tx) => {
|
|
105
|
+
requireCheckpoint(tx.store, checkpointId, userId);
|
|
106
|
+
return tx.moveCheckpoint(checkpointId, afterId);
|
|
107
|
+
});
|
|
108
|
+
return { id, text, position };
|
|
109
|
+
}
|
|
110
|
+
// --- Résultats et reviews -------------------------------------------------------
|
|
111
|
+
/** Résultat de l'utilisateur, testeur de la tâche, sur un point. */
|
|
112
|
+
export function setTestResult(store, userId, checkpointId, patch) {
|
|
113
|
+
const { result, comment, updatedAt } = store.mutate(asUser(userId), (tx) => {
|
|
114
|
+
const checkpoint = requireCheckpoint(tx.store, checkpointId, userId);
|
|
115
|
+
requireTester(tx.store, checkpoint.taskId, userId);
|
|
116
|
+
return tx.setTestResult(checkpointId, patch);
|
|
117
|
+
});
|
|
118
|
+
return { checkpointId, result, comment, updatedAt };
|
|
119
|
+
}
|
|
120
|
+
/** Enregistre le brouillon de review de l'utilisateur, testeur de la tâche. */
|
|
121
|
+
export function saveReviewDraft(store, userId, taskId, text) {
|
|
122
|
+
const review = store.mutate(asUser(userId), (tx) => {
|
|
123
|
+
requireTester(tx.store, taskId, userId);
|
|
124
|
+
return tx.saveReviewDraft(taskId, text);
|
|
125
|
+
});
|
|
126
|
+
return { draft: review.text };
|
|
127
|
+
}
|
|
128
|
+
/** Soumet la review de l'utilisateur : elle est figée et une demande `test_review` part dans la file de l'agent. */
|
|
129
|
+
export function submitReview(store, userId, taskId, text) {
|
|
130
|
+
const { review } = store.mutate(asUser(userId), (tx) => {
|
|
131
|
+
requireTester(tx.store, taskId, userId);
|
|
132
|
+
return tx.submitReview(taskId, text);
|
|
133
|
+
});
|
|
134
|
+
return submittedReviewView(store, review);
|
|
135
|
+
}
|