From 2599eb941eaa4db848c4a5a825efce369f3fd1ad Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Thu, 23 Apr 2026 22:54:40 +0200 Subject: [PATCH] feat: ticket cross-workspace migrations --- .../src/i18n/messages.de.xlf | 24 ++ .../src/i18n/messages.xlf | 18 ++ .../feature-agent-controller/README.md | 1 + ...sequence-http-ticket-workspace-migrate.mmd | 40 +++ .../docs/ticket-board-realtime.mmd | 2 + .../spec/asyncapi.yaml | 1 + .../spec/openapi.yaml | 51 ++++ .../src/lib/controllers/tickets.controller.ts | 6 + .../src/lib/dto/tickets/index.ts | 1 + .../src/lib/dto/tickets/migrate-ticket.dto.ts | 7 + .../src/lib/services/tickets.service.spec.ts | 77 ++++- .../src/lib/services/tickets.service.ts | 119 ++++++++ .../src/lib/services/tickets.service.spec.ts | 14 + .../src/lib/services/tickets.service.ts | 6 + .../src/lib/state/tickets/tickets.actions.ts | 9 + .../lib/state/tickets/tickets.effects.spec.ts | 36 +++ .../src/lib/state/tickets/tickets.effects.ts | 34 ++- .../lib/state/tickets/tickets.facade.spec.ts | 6 + .../src/lib/state/tickets/tickets.facade.ts | 5 + .../lib/state/tickets/tickets.reducer.spec.ts | 91 ++++++ .../src/lib/state/tickets/tickets.reducer.ts | 63 ++++- .../src/lib/state/tickets/tickets.types.ts | 8 + .../src/lib/agent-console.routes.ts | 2 + .../lib/tickets/tickets-board.component.html | 104 ++++++- .../lib/tickets/tickets-board.component.ts | 262 +++++++++++++++++- 25 files changed, 963 insertions(+), 24 deletions(-) create mode 100644 libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-ticket-workspace-migrate.mmd create mode 100644 libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/migrate-ticket.dto.ts diff --git a/apps/frontend-agent-console/src/i18n/messages.de.xlf b/apps/frontend-agent-console/src/i18n/messages.de.xlf index e91df6f3..e74bb516 100644 --- a/apps/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.de.xlf @@ -1120,6 +1120,30 @@ Open prototype in chat Prototyp im Chat öffnen + + Move ticket to another workspace + Ticket in einen anderen Arbeitsbereich verschieben + + + Move ticket to workspace + Ticket in Arbeitsbereich verschieben + + + The ticket and its subtasks in this workspace will move to the workspace you select. You must be a workspace admin in both places. + Das Ticket und seine Unteraufgaben in diesem Arbeitsbereich werden in den gewählten Arbeitsbereich verschoben. Sie müssen in beiden Arbeitsbereichen Administrator sein. + + + Target workspace + Ziel-Arbeitsbereich + + + Cancel + Abbrechen + + + Move ticket + Ticket verschieben + Generate description with AI (uses selected agent) Beschreibung per KI erzeugen (nutzt gewählten Agenten) diff --git a/apps/frontend-agent-console/src/i18n/messages.xlf b/apps/frontend-agent-console/src/i18n/messages.xlf index cd7427e5..f63e43eb 100644 --- a/apps/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.xlf @@ -1756,6 +1756,24 @@ Open prototype in chat + + Move ticket to another workspace + + + Move ticket to workspace + + + The ticket and its subtasks in this workspace will move to the workspace you select. You must be a workspace admin in both places. + + + Target workspace + + + Cancel + + + Move ticket + Loading ticket… diff --git a/libs/domains/framework/backend/feature-agent-controller/README.md b/libs/domains/framework/backend/feature-agent-controller/README.md index 089af7ff..4e18b765 100644 --- a/libs/domains/framework/backend/feature-agent-controller/README.md +++ b/libs/domains/framework/backend/feature-agent-controller/README.md @@ -73,6 +73,7 @@ All diagrams are available in the [`docs/`](./docs/) directory: - **[HTTP Statistics Sequence Diagram](./docs/sequence-http-statistics.mmd)** - Sequence diagram for Statistics REST API (client-scoped and aggregate endpoints) - **[HTTP Sequence Diagram](./docs/sequence-http.mmd)** - Detailed sequence diagram for all HTTP CRUD operations (client management and proxied agent operations) - **[HTTP Environment Variables Sequence Diagram](./docs/sequence-http-environment.mmd)** - Detailed sequence diagram for proxied environment variable operations +- **[Ticket workspace migration](./docs/sequence-http-ticket-workspace-migrate.mmd)** - `POST /tickets/:id/migrate` (workspace management on source and target) - **[HTTP VCS Sequence Diagram](./docs/sequence-http-vcs.mmd)** - Detailed sequence diagram for proxied VCS (Git) operations - **[WebSocket Forwarding Diagram](./docs/sequence-ws-forward.mmd)** - Sequence diagram for WebSocket connection, client context setup, event forwarding, and auto-login - **[Chat prompt enhancement](./docs/sequence-chat-enhancement.mmd)** - Sequence for `enhanceChat` / `chatEnhanceResult` (magic-wand flow; statistics only, no `agent_messages`) diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-ticket-workspace-migrate.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-ticket-workspace-migrate.mmd new file mode 100644 index 00000000..efa9a881 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-ticket-workspace-migrate.mmd @@ -0,0 +1,40 @@ +sequenceDiagram + participant Client as HTTP Client + participant API as TicketsController
POST /api/tickets/:id/migrate + participant Service as TicketsService + participant Access as ensureWorkspaceManagementAccess + participant DB as Database (tickets, ticket_activity) + participant Board as TicketBoardRealtimeService + participant Chat as ClientAutomationChatRealtimeService + + Note over Client,Chat: Workspace ticket migration (admin on source and target) + + Client->>API: POST /api/tickets/{id}/migrate
{ targetClientId } + API->>Service: migrateTicket(id, dto, req) + Service->>Service: assertTicketReadable (ensureClientAccess on ticket workspace) + Service->>Service: resolve root + collect subtree ids (same clientId) + Service->>Access: ensureWorkspaceManagementAccess(sourceClientId) + Access-->>Service: ok or 403 + Service->>Access: ensureWorkspaceManagementAccess(targetClientId) + Access-->>Service: ok or 403 + alt targetClientId === sourceClientId + Service-->>API: 400 Bad Request + API-->>Client: 400 + else authorized + Service->>DB: BEGIN — UPDATE tickets SET client_id = target WHERE id IN subtree + Service->>DB: INSERT ticket_activity WORKSPACE_MOVED (root) + Service->>DB: COMMIT + loop each migrated ticket id + Service->>Service: invalidateAfterTicketFieldChanges (automation approval) + end + loop each migrated ticket id + Service->>Board: emit ticketRemoved (sourceClientId) + end + loop each migrated row (mapped DTO) + Service->>Board: emit ticketUpsert (targetClientId) + Service->>Chat: emit ticketChatTicketUpsert (targetClientId) + end + Service->>Service: findOne(root, includeDescendants) + Service-->>API: { ticket } + API-->>Client: 200 MigrateTicketResponseDto + end diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/ticket-board-realtime.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/ticket-board-realtime.mmd index 6aa22a42..26f7fdba 100644 --- a/libs/domains/framework/backend/feature-agent-controller/docs/ticket-board-realtime.mmd +++ b/libs/domains/framework/backend/feature-agent-controller/docs/ticket-board-realtime.mmd @@ -19,3 +19,5 @@ sequenceDiagram RT->>Room: ticketUpsert Room-->>Facade: ticketUpsert Facade-->>Board: NgRx merge list or detail + + Note over Svc,RT: Workspace migration (POST /tickets/:id/migrate): for each id,
ticketRemoved on source room then ticketUpsert on target room diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml index fadc34af..d5feb89e 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml @@ -178,6 +178,7 @@ channels: description: | Full ticket row after create/update (aligns with OpenAPI TicketResponseDto). A single REST create with `creationTemplate: specification` emits one upsert per ticket (parent plus four subtasks). + `POST /tickets/{id}/migrate` emits one `ticketRemoved` per id on the source workspace, then one `ticketUpsert` per migrated row on the target workspace. tickets/ticketRemoved: address: tickets/ticketRemoved messages: diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml index 52de9e7f..bea1249b 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml @@ -631,6 +631,42 @@ paths: application/json: schema: $ref: '#/components/schemas/TicketResponseDto' + /tickets/{id}/migrate: + post: + summary: Migrate ticket subtree to another workspace + description: | + Moves the resolved root ticket and all of its descendant tickets that share the source workspace to + `targetClientId`. The caller may pass any ticket id in the subtree; the server always migrates the full tree. + Requires workspace management (see `canManageWorkspaceConfiguration` on clients) on **both** the source and + target workspaces. Emits `ticketRemoved` for each id on the source workspace room and `ticketUpsert` on the + target workspace room (tickets Socket.IO namespace). `targetClientId` must differ from the ticket workspace (400 otherwise). + operationId: migrateTicketToWorkspace + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MigrateTicketDto' + responses: + '200': + description: Migrated root ticket (includes `children` when descendants exist) + content: + application/json: + schema: + $ref: '#/components/schemas/MigrateTicketResponseDto' + '400': + description: Invalid request (e.g. target workspace equals source) + '403': + description: Missing workspace access or insufficient workspace role for source or target + '404': + description: Ticket not found /tickets/{ticketId}/automation: get: summary: Get ticket automation configuration @@ -4717,6 +4753,21 @@ components: description: Present when `creationTemplate` was `specification` (four subtasks). items: $ref: '#/components/schemas/TicketResponseDto' + MigrateTicketDto: + type: object + required: [targetClientId] + properties: + targetClientId: + type: string + format: uuid + description: Destination workspace (client) id + MigrateTicketResponseDto: + type: object + required: [ticket] + properties: + ticket: + $ref: '#/components/schemas/TicketResponseDto' + description: Root ticket after migration, including nested `children` when present UpdateTicketDto: type: object properties: diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts index 34322f2a..56c2d657 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts @@ -19,6 +19,7 @@ import { ApplyGeneratedBodyDto, CreateTicketCommentDto, CreateTicketDto, + MigrateTicketDto, StartBodyGenerationSessionDto, UpdateTicketDto, } from '../dto/tickets'; @@ -98,6 +99,11 @@ export class TicketsController { return await this.ticketsService.applyGeneratedBody(id, dto, req); } + @Post(':id/migrate') + async migrate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MigrateTicketDto, @Req() req?: RequestWithUser) { + return await this.ticketsService.migrateTicket(id, dto, req); + } + @Get(':id') async getOne( @Param('id', ParseUUIDPipe) id: string, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/index.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/index.ts index 551a02ba..d8e91437 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/index.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/index.ts @@ -1,6 +1,7 @@ export * from './apply-generated-body.dto'; export * from './create-ticket-comment.dto'; export * from './create-ticket.dto'; +export * from './migrate-ticket.dto'; export * from './start-body-generation-session.dto'; export * from './ticket-activity-response.dto'; export * from './ticket-comment-response.dto'; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/migrate-ticket.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/migrate-ticket.dto.ts new file mode 100644 index 00000000..c41eae58 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/migrate-ticket.dto.ts @@ -0,0 +1,7 @@ +import { IsUUID } from 'class-validator'; + +/** Body for POST /tickets/:id/migrate — moves the ticket subtree to another workspace. */ +export class MigrateTicketDto { + @IsUUID('4') + targetClientId!: string; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts index 4615b070..c87399a9 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ClientUsersRepository, UsersRepository } from '@forepath/identity/backend'; @@ -8,6 +8,7 @@ import { TicketCommentEntity } from '../entities/ticket-comment.entity'; import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketEntity } from '../entities/ticket.entity'; import { TicketCreationTemplate, TicketPriority, TicketStatus } from '../entities/ticket.enums'; +import { ensureWorkspaceManagementAccess } from '@forepath/identity/backend'; import { ClientsRepository } from '../repositories/clients.repository'; import { ClientsService } from './clients.service'; import { TicketAutomationService } from './ticket-automation.service'; @@ -20,6 +21,7 @@ jest.mock('@forepath/identity/backend', () => { return { ...actual, ensureClientAccess: jest.fn().mockResolvedValue(undefined), + ensureWorkspaceManagementAccess: jest.fn().mockResolvedValue(undefined), getUserFromRequest: jest.fn().mockReturnValue({ userId: 'user-1', userRole: 'admin', isApiKeyAuth: false }), }; }); @@ -287,4 +289,77 @@ describe('TicketsService', () => { ).rejects.toThrow(BadRequestException); }); }); + + describe('migrateTicket', () => { + const targetClientId = '90000000-9000-4000-8000-0000000000c2'; + + afterEach(() => { + (ensureWorkspaceManagementAccess as jest.Mock).mockResolvedValue(undefined); + }); + + it('rejects when target workspace equals source', async () => { + await expect(service.migrateTicket(ticketId, { targetClientId: clientId }, undefined)).rejects.toThrow( + BadRequestException, + ); + }); + + it('propagates Forbidden when workspace management is denied', async () => { + (ensureWorkspaceManagementAccess as jest.Mock).mockRejectedValueOnce(new ForbiddenException('no')); + await expect(service.migrateTicket(ticketId, { targetClientId: targetClientId }, undefined)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('requires workspace management on source and target before updating', async () => { + (ensureWorkspaceManagementAccess as jest.Mock).mockClear(); + ticket.parentId = null; + ticket.clientId = clientId; + ticketRepo.findOne.mockResolvedValue({ ...ticket }); + ticketRepo.find.mockImplementation(async (opts?: { where?: Record }) => { + const w = opts?.where; + if (w && w.clientId === clientId) { + return [{ id: ticketId, parentId: null } as TicketEntity]; + } + if (w && w.clientId === targetClientId) { + return [{ ...ticket, clientId: targetClientId } as TicketEntity]; + } + if (w && Object.prototype.hasOwnProperty.call(w, 'id')) { + return [{ ...ticket, clientId: targetClientId } as TicketEntity]; + } + return []; + }); + ticketRepo.manager.transaction.mockImplementation(async (fn: (em: unknown) => Promise) => { + const em = { + getRepository: (entity: unknown) => { + if (entity === TicketEntity) { + return { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + } + if (entity === TicketActivityEntity) { + return activityRepo; + } + throw new Error(`Unexpected entity ${String(entity)}`); + }, + }; + return fn(em); + }); + + await service.migrateTicket(ticketId, { targetClientId: targetClientId }, undefined); + + expect(ensureWorkspaceManagementAccess).toHaveBeenCalledTimes(2); + expect(ensureWorkspaceManagementAccess).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + clientId, + undefined, + ); + expect(ensureWorkspaceManagementAccess).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + targetClientId, + undefined, + ); + }); + }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts index 713f0ca0..e7219542 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts @@ -1,6 +1,7 @@ import { ClientUsersRepository, ensureClientAccess, + ensureWorkspaceManagementAccess, getUserFromRequest, type RequestWithUser, UsersRepository, @@ -21,6 +22,7 @@ import { CreateTicketCommentDto, CreateTicketDto, CreateTicketResponseDto, + MigrateTicketDto, PrototypePromptResponseDto, StartBodyGenerationSessionDto, StartBodyGenerationSessionResponseDto, @@ -186,6 +188,51 @@ export class TicketsService { return ticket; } + private async resolveRootTicket(ticket: TicketEntity): Promise { + let current = ticket; + while (current.parentId) { + const parent = await this.loadTicketOrThrow(current.parentId); + if (parent.clientId !== ticket.clientId) { + throw new BadRequestException('Ticket hierarchy spans multiple workspaces'); + } + current = parent; + } + return current; + } + + /** + * All ticket ids in the subtree under `rootId` within `sourceClientId` (root inclusive), depth-first order. + */ + private async collectSubtreeTicketIds(rootId: string, sourceClientId: string): Promise { + const all = await this.ticketRepo.find({ + where: { clientId: sourceClientId }, + select: ['id', 'parentId'], + }); + const byParent = new Map(); + for (const t of all) { + const p = t.parentId ?? null; + if (!byParent.has(p)) { + byParent.set(p, []); + } + byParent.get(p)!.push(t.id); + } + const out: string[] = []; + const stack = [rootId]; + const visited = new Set(); + while (stack.length > 0) { + const id = stack.pop()!; + if (visited.has(id)) { + continue; + } + visited.add(id); + out.push(id); + for (const childId of byParent.get(id) ?? []) { + stack.push(childId); + } + } + return out; + } + async listTickets(query: TicketListQuery, req?: RequestWithUser): Promise { const accessible = await this.getAccessibleClientIds(req); const qb = this.ticketRepo.createQueryBuilder('t'); @@ -501,6 +548,78 @@ export class TicketsService { return mapped; } + /** + * Moves the ticket subtree (root of the given ticket and all descendants in the same workspace) to + * `targetClientId`. Requires workspace management (owner / client admin / global admin / API key) on + * both source and target workspaces. + */ + async migrateTicket( + ticketId: string, + dto: MigrateTicketDto, + req?: RequestWithUser, + ): Promise<{ ticket: TicketResponseDto }> { + const targetClientId = dto.targetClientId; + const seed = await this.assertTicketReadable(ticketId, req); + const sourceClientId = seed.clientId; + if (targetClientId === sourceClientId) { + throw new BadRequestException('Target workspace must differ from the ticket workspace'); + } + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, sourceClientId, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, targetClientId, req); + const root = await this.resolveRootTicket(seed); + const idsToMigrate = await this.collectSubtreeTicketIds(root.id, sourceClientId); + const actor = this.resolveActor(req); + + await this.ticketRepo.manager.transaction(async (em: EntityManager) => { + const tRepo = em.getRepository(TicketEntity); + const aRepo = em.getRepository(TicketActivityEntity); + await tRepo.update({ id: In(idsToMigrate) }, { clientId: targetClientId }); + await aRepo.save( + aRepo.create({ + ticketId: root.id, + actorType: actor.actorType, + actorUserId: actor.actorUserId ?? null, + actionType: TicketActionType.WORKSPACE_MOVED, + payload: { + fromClientId: sourceClientId, + toClientId: targetClientId, + migratedTicketIds: idsToMigrate, + }, + }), + ); + }); + + for (const id of idsToMigrate) { + await this.ticketAutomationService.invalidateAfterTicketFieldChanges(id, ['clientId'], req); + } + + for (const id of idsToMigrate) { + this.boardEmitTicketRemoved(sourceClientId, id); + } + + const rowsAfter = await this.ticketRepo.find({ + where: { id: In(idsToMigrate) }, + order: { createdAt: 'ASC' }, + }); + for (const row of rowsAfter) { + const mapped = await this.mapTicketInWorkspace(row); + this.boardEmitTicketUpsert(targetClientId, mapped); + this.clientAutomationChatRealtime.emitTicketChatUpsert(targetClientId, mapped); + } + + const activityRows = await this.activityRepo.find({ + where: { ticketId: root.id }, + order: { occurredAt: 'DESC' }, + take: 1, + }); + if (activityRows[0]) { + await this.boardEmitTicketActivityMapped(targetClientId, activityRows[0]); + } + + const ticket = await this.findOne(root.id, true, req); + return { ticket }; + } + private async wouldCreateCycle(ticketId: string, newParentId: string): Promise { let current: string | null | undefined = newParentId; const visited = new Set(); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts index 052c2022..79cc36d1 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts @@ -237,4 +237,18 @@ describe('TicketsService', () => { req.flush({ ...mockRun, status: 'cancelled' }); }); }); + + describe('migrateTicket', () => { + it('POSTs /tickets/:id/migrate with body', (done) => { + const body = { targetClientId: 'client-2' }; + service.migrateTicket('ticket-1', body).subscribe((res) => { + expect(res).toEqual({ ticket: { ...mockTicket, clientId: 'client-2' } }); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/migrate`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(body); + req.flush({ ticket: { ...mockTicket, clientId: 'client-2' } }); + }); + }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts index dac7ba86..f28f42ce 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts @@ -7,6 +7,8 @@ import type { CreateTicketDto, CreateTicketResultDto, ListTicketsParams, + MigrateTicketDto, + MigrateTicketResultDto, PrototypePromptResponseDto, StartBodyGenerationSessionResponseDto, TicketActivityResponseDto, @@ -63,6 +65,10 @@ export class TicketsService { return this.http.patch(`${this.apiUrl}/tickets/${id}`, dto); } + migrateTicket(id: string, dto: MigrateTicketDto): Observable { + return this.http.post(`${this.apiUrl}/tickets/${id}/migrate`, dto); + } + deleteTicket(id: string): Observable { return this.http.delete(`${this.apiUrl}/tickets/${id}`); } diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts index 9fc66f19..7034e419 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts @@ -47,6 +47,15 @@ export const updateTicketSuccess = createAction( export const updateTicketFailure = createAction('[Tickets] Update Failure', props<{ error: string }>()); +export const migrateTicket = createAction('[Tickets] Migrate', props<{ id: string; targetClientId: string }>()); + +export const migrateTicketSuccess = createAction( + '[Tickets] Migrate Success', + props<{ rootTicket: TicketResponseDto; migratedTicketIds: string[]; requestedTicketId: string }>(), +); + +export const migrateTicketFailure = createAction('[Tickets] Migrate Failure', props<{ error: string }>()); + export const deleteTicket = createAction('[Tickets] Delete', props<{ id: string }>()); export const deleteTicketSuccess = createAction('[Tickets] Delete Success', props<{ id: string }>()); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts index b45a6c26..2ba8632e 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts @@ -16,6 +16,9 @@ import { loadTicketsFailure, loadTicketsSuccess, loadTicketDetailBundleSuccess, + migrateTicket, + migrateTicketFailure, + migrateTicketSuccess, openTicketDetail, updateTicket, updateTicketFailure, @@ -26,6 +29,7 @@ import { createTicket$, deleteTicket$, loadTickets$, + migrateTicket$, openTicketDetail$, updateTicket$, } from './tickets.effects'; @@ -55,6 +59,7 @@ describe('TicketsEffects', () => { listActivity: jest.fn(), createTicket: jest.fn(), updateTicket: jest.fn(), + migrateTicket: jest.fn(), deleteTicket: jest.fn(), addComment: jest.fn(), } as unknown as jest.Mocked; @@ -205,6 +210,37 @@ describe('TicketsEffects', () => { }); }); + describe('migrateTicket$', () => { + it('should return migrateTicketSuccess with collected ids', (done) => { + const child = { ...mockTicket, id: 'child-1', parentId: 'ticket-1', clientId: 'client-2' }; + const root = { ...mockTicket, clientId: 'client-2', children: [child] }; + const action = migrateTicket({ id: 'child-1', targetClientId: 'client-2' }); + const outcome = migrateTicketSuccess({ + rootTicket: root, + migratedTicketIds: ['ticket-1', 'child-1'], + requestedTicketId: 'child-1', + }); + actions$ = of(action); + ticketsService.migrateTicket.mockReturnValue(of({ ticket: root })); + migrateTicket$(actions$, ticketsService).subscribe((result) => { + expect(result).toEqual(outcome); + expect(ticketsService.migrateTicket).toHaveBeenCalledWith('child-1', { targetClientId: 'client-2' }); + done(); + }); + }); + + it('should return migrateTicketFailure on error', (done) => { + const action = migrateTicket({ id: 'ticket-1', targetClientId: 'client-2' }); + const outcome = migrateTicketFailure({ error: 'forbidden' }); + actions$ = of(action); + ticketsService.migrateTicket.mockReturnValue(throwError(() => new Error('forbidden'))); + migrateTicket$(actions$, ticketsService).subscribe((result) => { + expect(result).toEqual(outcome); + done(); + }); + }); + }); + describe('addTicketComment$', () => { it('should return addTicketCommentSuccess with refreshed activity', (done) => { const comment = { diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.ts index e7a84051..3b6d7e8d 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; -import type { TicketActivityResponseDto } from './tickets.types'; +import type { TicketActivityResponseDto, TicketResponseDto } from './tickets.types'; import { TicketsService } from '../../services/tickets.service'; import { addTicketComment, @@ -19,6 +19,9 @@ import { loadTickets, loadTicketsFailure, loadTicketsSuccess, + migrateTicket, + migrateTicketFailure, + migrateTicketSuccess, openTicketDetail, updateTicket, updateTicketFailure, @@ -38,6 +41,14 @@ function normalizeError(error: unknown): string { return 'An unexpected error occurred'; } +function collectTicketTreeIds(root: TicketResponseDto): string[] { + const ids = [root.id]; + for (const c of root.children ?? []) { + ids.push(...collectTicketTreeIds(c)); + } + return ids; +} + export const loadTickets$ = createEffect( (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { return actions$.pipe( @@ -115,6 +126,27 @@ export const updateTicket$ = createEffect( { functional: true }, ); +export const migrateTicket$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(migrateTicket), + switchMap(({ id, targetClientId }) => + ticketsService.migrateTicket(id, { targetClientId }).pipe( + map((res) => + migrateTicketSuccess({ + rootTicket: res.ticket, + migratedTicketIds: collectTicketTreeIds(res.ticket), + requestedTicketId: id, + }), + ), + catchError((error) => of(migrateTicketFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + export const deleteTicket$ = createEffect( (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { return actions$.pipe( diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts index 9892165d..274ad261 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts @@ -8,6 +8,7 @@ import { createTicket, deleteTicket, loadTickets, + migrateTicket, openTicketDetail, updateTicket, } from './tickets.actions'; @@ -194,6 +195,11 @@ describe('TicketsFacade', () => { expect(store.dispatch).toHaveBeenCalledWith(updateTicket({ id: 'ticket-1', dto })); }); + it('dispatches migrateTicket', () => { + facade.migrateTicket('ticket-1', 'client-2'); + expect(store.dispatch).toHaveBeenCalledWith(migrateTicket({ id: 'ticket-1', targetClientId: 'client-2' })); + }); + it('dispatches deleteTicket', () => { facade.remove('ticket-1'); expect(store.dispatch).toHaveBeenCalledWith(deleteTicket({ id: 'ticket-1' })); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts index 8f9fc839..f20ffbdf 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts @@ -16,6 +16,7 @@ import { createTicket, deleteTicket, loadTickets, + migrateTicket as migrateTicketAction, openTicketDetail, prependTicketDetailActivity, updateTicket, @@ -72,6 +73,10 @@ export class TicketsFacade { this.store.dispatch(updateTicket({ id, dto })); } + migrateTicket(id: string, targetClientId: string): void { + this.store.dispatch(migrateTicketAction({ id, targetClientId })); + } + remove(id: string): void { this.store.dispatch(deleteTicket({ id })); } diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts index ff9a75e1..d6ed26e3 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts @@ -11,6 +11,9 @@ import { loadTickets, loadTicketsFailure, loadTicketsSuccess, + migrateTicket, + migrateTicketFailure, + migrateTicketSuccess, openTicketDetail, prependTicketDetailActivity, replaceTicketDetailActivity, @@ -521,6 +524,21 @@ describe('ticketsReducer', () => { expect(next.detail).toBeNull(); expect(next.selectedTicketId).toBeNull(); }); + + it('does not clear detail or drop list row when removal targets another workspace', () => { + const migrated = { ...mockTicket, clientId: 'client-2' }; + const prev: TicketsState = { + ...initialTicketsState, + list: [migrated], + detail: migrated, + selectedTicketId: mockTicket.id, + }; + const next = ticketsReducer(prev, ticketBoardTicketRemoved({ id: mockTicket.id, clientId: mockTicket.clientId })); + expect(next.list).toHaveLength(1); + expect(next.list[0]).toMatchObject(migrated); + expect(next.detail).toMatchObject(migrated); + expect(next.selectedTicketId).toBe(mockTicket.id); + }); }); describe('board socket comment', () => { @@ -546,4 +564,77 @@ describe('ticketsReducer', () => { expect(next.activity).toEqual([mockActivity]); }); }); + + describe('migrateTicket', () => { + const child: TicketResponseDto = { + ...mockTicket, + id: 'ticket-child', + parentId: mockTicket.id, + clientId: 'client-1', + }; + const migratedRoot: TicketResponseDto = { + ...mockTicket, + clientId: 'client-2', + children: [{ ...child, clientId: 'client-2' }], + }; + + it('sets saving on migrateTicket', () => { + const next = ticketsReducer( + initialTicketsState, + migrateTicket({ id: 'ticket-child', targetClientId: 'client-2' }), + ); + expect(next.saving).toBe(true); + expect(next.error).toBeNull(); + }); + + it('replaces list rows and detail on migrateTicketSuccess', () => { + const prev: TicketsState = { + ...initialTicketsState, + list: [mockTicket, child], + detail: child, + selectedTicketId: child.id, + saving: true, + }; + const next = ticketsReducer( + prev, + migrateTicketSuccess({ + rootTicket: migratedRoot, + migratedTicketIds: [mockTicket.id, child.id], + requestedTicketId: child.id, + }), + ); + expect(next.saving).toBe(false); + expect(next.list.map((t) => t.id).sort()).toEqual([mockTicket.id, child.id].sort()); + expect(next.list.every((t) => t.clientId === 'client-2')).toBe(true); + expect(next.detail?.id).toBe(child.id); + expect(next.detail?.clientId).toBe('client-2'); + }); + + it('updates migrated detail from detail.id when selectedTicketId is null', () => { + const prev: TicketsState = { + ...initialTicketsState, + list: [mockTicket, child], + detail: child, + selectedTicketId: null, + saving: true, + }; + const next = ticketsReducer( + prev, + migrateTicketSuccess({ + rootTicket: migratedRoot, + migratedTicketIds: [mockTicket.id, child.id], + requestedTicketId: child.id, + }), + ); + expect(next.detail?.id).toBe(child.id); + expect(next.detail?.clientId).toBe('client-2'); + }); + + it('clears saving on migrateTicketFailure', () => { + const prev = { ...initialTicketsState, saving: true }; + const next = ticketsReducer(prev, migrateTicketFailure({ error: 'x' })); + expect(next.saving).toBe(false); + expect(next.error).toBe('x'); + }); + }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts index 6b138e91..b5c19169 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts @@ -24,6 +24,9 @@ import { loadTickets, loadTicketsFailure, loadTicketsSuccess, + migrateTicket, + migrateTicketFailure, + migrateTicketSuccess, openTicketDetail, prependTicketDetailActivity, replaceTicketDetailActivity, @@ -113,6 +116,32 @@ function mergeCreatedChildIntoDetail( }; } +function flattenTicketSubtreeForList(root: TicketResponseDto): TicketResponseDto[] { + const acc: TicketResponseDto[] = []; + const visit = (t: TicketResponseDto): void => { + const { children, ...rest } = t; + acc.push({ ...rest, children: undefined }); + for (const c of children ?? []) { + visit(c); + } + }; + visit(root); + return acc; +} + +function findTicketInTree(root: TicketResponseDto, id: string): TicketResponseDto | null { + if (root.id === id) { + return root; + } + for (const c of root.children ?? []) { + const found = findTicketInTree(c, id); + if (found) { + return found; + } + } + return null; +} + export const ticketsReducer = createReducer( initialTicketsState, on(loadTickets, (state) => ({ ...state, loadingList: true, error: null })), @@ -187,6 +216,30 @@ export const ticketsReducer = createReducer( }; }), on(updateTicketFailure, (state, { error }) => ({ ...state, saving: false, error })), + on(migrateTicket, (state) => ({ ...state, saving: true, error: null })), + on(migrateTicketSuccess, (state, { rootTicket, migratedTicketIds, requestedTicketId }) => { + let list = state.list.filter((t) => !migratedTicketIds.includes(t.id)); + for (const row of flattenTicketSubtreeForList(rootTicket)) { + list = mergeTicketInList(list, row); + } + const focusFromRequest = migratedTicketIds.includes(requestedTicketId) ? requestedTicketId : null; + const focusId = focusFromRequest ?? state.selectedTicketId ?? state.detail?.id ?? null; + let detail = state.detail; + if (focusId && migratedTicketIds.includes(focusId)) { + const fromTree = findTicketInTree(rootTicket, focusId); + const fromList = list.find((t) => t.id === focusId) ?? null; + detail = fromTree ?? fromList ?? state.detail; + } + const enriched = enrichTicketsWithSubtaskCounts(list, detail); + return { + ...state, + saving: false, + list: enriched.list, + detail: enriched.detail, + error: null, + }; + }), + on(migrateTicketFailure, (state, { error }) => ({ ...state, saving: false, error })), on(deleteTicket, (state) => ({ ...state, saving: true, error: null })), on(deleteTicketSuccess, (state, { id }) => { const list = state.list.filter((t) => t.id !== id); @@ -252,10 +305,12 @@ export const ticketsReducer = createReducer( detail: enriched.detail, }; }), - on(ticketBoardTicketRemoved, (state, { id }) => { - const list = state.list.filter((t) => t.id !== id); - const selectedTicketId = state.selectedTicketId === id ? null : state.selectedTicketId; - const detail = state.detail?.id === id ? null : state.detail; + on(ticketBoardTicketRemoved, (state, { id, clientId }) => { + const list = state.list.filter((t) => !(t.id === id && t.clientId === clientId)); + const clearDetail = state.detail?.id === id && state.detail.clientId === clientId; + const detail = clearDetail ? null : state.detail; + const selectedTicketId = + clearDetail || (!state.detail && state.selectedTicketId === id) ? null : state.selectedTicketId; const enriched = enrichTicketsWithSubtaskCounts(list, detail); return { ...state, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts index 5313052f..ab07889e 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts @@ -75,6 +75,14 @@ export interface UpdateTicketDto { preferredChatAgentId?: string | null; } +export interface MigrateTicketDto { + targetClientId: string; +} + +export interface MigrateTicketResultDto { + ticket: TicketResponseDto; +} + export interface TicketCommentResponseDto { id: string; ticketId: string; diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts index f118a075..05542151 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts @@ -119,6 +119,7 @@ import { loadTicketAutomationRunDetail$, loadTicketAutomationRuns$, loadTickets$, + migrateTicket$, openTicketDetail$, TicketAutomationFacade, ticketAutomationReducer, @@ -367,6 +368,7 @@ export const agentConsoleRoutes: Route[] = [ openTicketDetail$, createTicket$, updateTicket$, + migrateTicket$, deleteTicket$, addTicketComment$, loadTicketAutomation$, diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html index 69ac1a7c..fd032e74 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html @@ -305,15 +305,29 @@
Tickets
} - +
+ + @if (effectiveWorkspace()?.client?.canManageWorkspaceConfiguration) { + + } +
} @@ -1105,6 +1119,76 @@

Activity

+ + + - +