Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/frontend-agent-console/src/i18n/messages.de.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,30 @@
<source> Open prototype in chat </source>
<target>Prototyp im Chat öffnen</target>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateWorkspaceTitle" datatype="html">
<source>Move ticket to another workspace</source>
<target>Ticket in einen anderen Arbeitsbereich verschieben</target>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateModalTitle" datatype="html">
<source>Move ticket to workspace</source>
<target>Ticket in Arbeitsbereich verschieben</target>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateModalIntro" datatype="html">
<source>The ticket and its subtasks in this workspace will move to the workspace you select. You must be a workspace admin in both places.</source>
<target>Das Ticket und seine Unteraufgaben in diesem Arbeitsbereich werden in den gewählten Arbeitsbereich verschoben. Sie müssen in beiden Arbeitsbereichen Administrator sein.</target>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateTargetLabel" datatype="html">
<source>Target workspace</source>
<target>Ziel-Arbeitsbereich</target>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateCancel" datatype="html">
<source>Cancel</source>
<target>Abbrechen</target>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateConfirm" datatype="html">
<source>Move ticket</source>
<target>Ticket verschieben</target>
</trans-unit>
<trans-unit id="featureTicketsBoard-generateBodyTitle" datatype="html">
<source>Generate description with AI (uses selected agent)</source>
<target>Beschreibung per KI erzeugen (nutzt gewählten Agenten)</target>
Expand Down
18 changes: 18 additions & 0 deletions apps/frontend-agent-console/src/i18n/messages.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -1756,6 +1756,24 @@
<trans-unit id="featureTicketsBoard-prototypeButton" datatype="html">
<source> Open prototype in chat </source>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateWorkspaceTitle" datatype="html">
<source>Move ticket to another workspace</source>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateModalTitle" datatype="html">
<source>Move ticket to workspace</source>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateModalIntro" datatype="html">
<source>The ticket and its subtasks in this workspace will move to the workspace you select. You must be a workspace admin in both places.</source>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateTargetLabel" datatype="html">
<source>Target workspace</source>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateCancel" datatype="html">
<source>Cancel</source>
</trans-unit>
<trans-unit id="featureTicketsBoard-migrateConfirm" datatype="html">
<source>Move ticket</source>
</trans-unit>
<trans-unit id="featureTicketsBoard-detailLoading" datatype="html">
<source>Loading ticket…</source>
</trans-unit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
sequenceDiagram
participant Client as HTTP Client
participant API as TicketsController<br/>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<br/>{ 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
Original file line number Diff line number Diff line change
Expand Up @@ -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,<br/>ticketRemoved on source room then ticketUpsert on target room
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ApplyGeneratedBodyDto,
CreateTicketCommentDto,
CreateTicketDto,
MigrateTicketDto,
StartBodyGenerationSessionDto,
UpdateTicketDto,
} from '../dto/tickets';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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 }),
};
});
Expand Down Expand Up @@ -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<string, unknown> }) => {
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<unknown>) => {
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,
);
});
});
});
Loading
Loading