Files
IdeA/frontend/src/adapters/ticket.ts
Blomios 712b19ed10 fix(tickets): normalise createdBy manquant sur les payloads legacy
Les tickets legacy sans champ createdBy faisaient noircir la fenêtre
tickets côté frontend. Les adapters (ticket.ts, streamGateways.ts)
normalisent désormais ce champ absent, avec couverture de test dédiée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:19:50 +02:00

340 lines
9.8 KiB
TypeScript

/**
* Tauri adapter for {@link TicketGateway}.
*
* The single frontend owner of the `ticket_*` command names and their request
* envelopes (every command takes one camelCase `request` object). Features
* consume the gateway port through DI and never call `invoke()` directly.
*
* Optimistic concurrency: the backend rejects a stale write with an `INVALID`
* {@link GatewayError} whose message contains `"version conflict"`; use
* {@link isTicketVersionConflict} to branch on it in the UI.
*/
import { Channel, invoke } from "@tauri-apps/api/core";
import type {
GatewayError,
ReplyChunk,
Sprint,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
TicketLinkKind,
TicketList,
} from "@/domain";
import type {
CreateTicketInput,
TicketGateway,
TicketListQuery,
UpdateTicketInput,
} from "@/ports";
/**
* Whether a gateway error is an optimistic-concurrency conflict (the ticket was
* mutated since it was read). The backend returns a generic `INVALID` code, so
* we key off the stable message fragment emitted by the domain store error.
*/
export function isTicketVersionConflict(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const message = (error as GatewayError).message;
return typeof message === "string" && message.includes("version conflict");
}
const LEGACY_TICKET_ACTOR: Ticket["createdBy"] = { kind: "user" };
function normalizeTicketActor(actor: unknown): Ticket["createdBy"] {
if (!actor || typeof actor !== "object") return LEGACY_TICKET_ACTOR;
const rec = actor as Record<string, unknown>;
if (rec.kind === "user" || rec.kind === "system") return { kind: rec.kind };
if (rec.kind === "agent" && typeof rec.agentId === "string" && rec.agentId) {
return { kind: "agent", agentId: rec.agentId };
}
return LEGACY_TICKET_ACTOR;
}
function normalizeTicket(ticket: Ticket): Ticket {
return {
...ticket,
links: Array.isArray(ticket.links) ? ticket.links : [],
assignedAgentIds: Array.isArray(ticket.assignedAgentIds)
? ticket.assignedAgentIds
: [],
attachments: Array.isArray(ticket.attachments) ? ticket.attachments : [],
createdBy: normalizeTicketActor(ticket.createdBy),
updatedBy: normalizeTicketActor(ticket.updatedBy),
};
}
function normalizeTicketList(list: TicketList): TicketList {
return {
...list,
items: Array.isArray(list.items)
? list.items.map((item) => ({
...item,
assignedAgentIds: Array.isArray(item.assignedAgentIds)
? item.assignedAgentIds
: [],
createdBy: normalizeTicketActor(item.createdBy),
}))
: [],
};
}
export class TauriTicketGateway implements TicketGateway {
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_create", {
request: { projectId, ...input },
}));
}
async read(
projectId: string,
ref: string,
includeCarnet = false,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_read", {
request: { projectId, ref, includeCarnet },
}));
}
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const { statuses, priorities, ...rest } = query ?? {};
// Multi-select facets (ticket #12): the backend expects `statuses`/
// `priorities` arrays (empty ⇒ no constraint on that facet).
return normalizeTicketList(await invoke<TicketList>("ticket_list", {
request: {
projectId,
statuses: statuses ?? [],
priorities: priorities ?? [],
...rest,
},
}));
}
async update(
projectId: string,
ref: string,
input: UpdateTicketInput,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_update", {
request: { projectId, ref, ...input },
}));
}
async delete(projectId: string, ref: string): Promise<void> {
// `ticket_delete` takes the shared `{ request }` envelope like every other
// ticket command; a missing ref surfaces as a `NOT_FOUND` GatewayError from
// the backend, propagated as-is.
await invoke<void>("ticket_delete", {
request: { projectId, ref },
});
}
async bulkUpdateStatus(
projectId: string,
refs: string[],
status: Ticket["status"],
): Promise<TicketBulkResult> {
return invoke<TicketBulkResult>("ticket_bulk_update_status", {
request: { projectId, refs, status },
});
}
async bulkUpdatePriority(
projectId: string,
refs: string[],
priority: Ticket["priority"],
): Promise<TicketBulkResult> {
return invoke<TicketBulkResult>("ticket_bulk_update_priority", {
request: { projectId, refs, priority },
});
}
async bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
return invoke<TicketBulkResult>("ticket_bulk_delete", {
request: { projectId, refs },
});
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return invoke<TicketCarnet>("ticket_read_carnet", {
request: { projectId, ref },
});
}
async updateCarnet(
projectId: string,
ref: string,
carnet: string,
expectedVersion: number,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_update_carnet", {
request: { projectId, ref, carnet, expectedVersion },
}));
}
async addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_attachment_add", {
request: { projectId, ref, path, expectedVersion, mime },
}));
}
async readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
return invoke<TicketAttachmentContent>("ticket_attachment_read", {
request: { projectId, ref, attachmentId },
});
}
async markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_attachment_mark_summarized", {
request: { projectId, ref, attachmentId, expectedVersion },
}));
}
async link(
projectId: string,
ref: string,
targetRef: string,
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_link", {
request: { projectId, ref, targetRef, kind, expectedVersion },
}));
}
async unlink(
projectId: string,
ref: string,
targetRef: string,
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_unlink", {
request: { projectId, ref, targetRef, expectedVersion, kind },
}));
}
async assign(
projectId: string,
ref: string,
agentId: string,
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
return normalizeTicket(await invoke<Ticket>("ticket_assign", {
request: { projectId, ref, agentId, assigned, expectedVersion },
}));
}
async listSprints(projectId: string): Promise<Sprint[]> {
// `sprint_list` returns a `SprintListDto { items }`; unwrap to the array.
const list = await invoke<{ items: Sprint[] }>("sprint_list", {
request: { projectId },
});
return list.items;
}
async setTicketSprint(
projectId: string,
ref: string,
sprintId: string | null,
expectedVersion: number,
): Promise<Ticket> {
// Two backend commands: assign to a sprint, or unassign (clear membership).
if (sprintId === null) {
return normalizeTicket(await invoke<Ticket>("ticket_unassign_sprint", {
request: { projectId, ref, expectedVersion },
}));
}
return normalizeTicket(await invoke<Ticket>("ticket_assign_sprint", {
request: { projectId, ref, sprintId, expectedVersion },
}));
}
async createSprint(projectId: string, name: string): Promise<Sprint> {
return invoke<Sprint>("sprint_create", {
request: { projectId, name },
});
}
async renameSprint(
projectId: string,
sprintId: string,
name: string,
expectedVersion: number,
): Promise<Sprint> {
return invoke<Sprint>("sprint_rename", {
request: { projectId, sprintId, name, expectedVersion },
});
}
async reorderSprints(
projectId: string,
orderedIds: string[],
): Promise<Sprint[]> {
// `sprint_reorder` returns a `SprintListDto { items }`; unwrap to the array.
const list = await invoke<{ items: Sprint[] }>("sprint_reorder", {
request: { projectId, orderedIds },
});
return list.items;
}
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
await invoke<void>("sprint_delete", {
request: { projectId, sprintId },
});
}
async openTicketChat(
projectId: string,
issueRef: string,
profileId: string,
): Promise<TicketChat> {
return invoke<TicketChat>("open_ticket_chat", {
request: { projectId, issueRef, profileId },
});
}
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
await invoke<void>("close_ticket_chat", {
request: { projectId, issueRef },
});
}
async sendTicketChat(
sessionId: string,
message: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void> {
// Per-turn reply channel, tagged on `kind` (`textDelta`|`toolActivity`|
// `final`). Reuses the shared `agent_send` chat transport (the same command
// and ChatBridge the structured chat cell uses). Wire the handler before the
// invoke so no early chunk is missed.
const channel = new Channel<ReplyChunk>();
channel.onmessage = (chunk) => onChunk(chunk);
await invoke<void>("agent_send", {
sessionId,
prompt: message,
onReply: channel,
});
}
}