diff --git a/apps/agenstra/backend-agent-controller/src/migrations/1791900000000_CreateAgenstraAttachments.ts b/apps/agenstra/backend-agent-controller/src/migrations/1791900000000_CreateAgenstraAttachments.ts new file mode 100644 index 000000000..7a82114c1 --- /dev/null +++ b/apps/agenstra/backend-agent-controller/src/migrations/1791900000000_CreateAgenstraAttachments.ts @@ -0,0 +1,37 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAgenstraAttachments1791900000000 implements MigrationInterface { + name = 'CreateAgenstraAttachments1791900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "agenstra_attachments" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "client_id" uuid NOT NULL, + "owner_type" character varying(32) NOT NULL, + "owner_id" uuid NOT NULL, + "file_name" character varying(255) NOT NULL, + "content_type" character varying(255) NOT NULL, + "byte_size" bigint NOT NULL, + "storage_key" character varying(512) NOT NULL, + "content_sha256" character varying(64) NOT NULL, + "created_by_user_id" uuid, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_agenstra_attachments" PRIMARY KEY ("id"), + CONSTRAINT "FK_agenstra_attachments_client" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE, + CONSTRAINT "FK_agenstra_attachments_user" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL + ) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_agenstra_attachments_client" ON "agenstra_attachments" ("client_id")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_agenstra_attachments_owner" ON "agenstra_attachments" ("owner_type", "owner_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "agenstra_attachments"`); + } +} diff --git a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts index 4cd0bf748..fc6595283 100644 --- a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts +++ b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts @@ -26,6 +26,7 @@ import { TicketAutomationRunStepEntity, TicketAutomationEntity, ChatPlanEntity, + AgenstraAttachmentEntity, TicketBodyGenerationSessionEntity, TicketCommentEntity, TicketEntity, @@ -105,6 +106,7 @@ export const typeormConfig: DataSourceOptions = { TicketAutomationLeaseEntity, TicketAutomationRunStepEntity, ChatPlanEntity, + AgenstraAttachmentEntity, ClientAgentAutonomyEntity, AtlassianSiteConnectionEntity, ExternalImportConfigEntity, diff --git a/apps/agenstra/backend-agent-manager/src/migrations/1791800000000_AddAgentMessageAttachments.ts b/apps/agenstra/backend-agent-manager/src/migrations/1791800000000_AddAgentMessageAttachments.ts new file mode 100644 index 000000000..24ebc5107 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/src/migrations/1791800000000_AddAgentMessageAttachments.ts @@ -0,0 +1,13 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAgentMessageAttachments1791800000000 implements MigrationInterface { + name = 'AddAgentMessageAttachments1791800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "agent_messages" ADD COLUMN IF NOT EXISTS "attachments" jsonb`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "agent_messages" DROP COLUMN IF EXISTS "attachments"`); + } +} diff --git a/apps/decabill/backend-billing-manager/src/migrations/1778200000000_CreateProjectTicketAttachments.ts b/apps/decabill/backend-billing-manager/src/migrations/1778200000000_CreateProjectTicketAttachments.ts new file mode 100644 index 000000000..6a7af499e --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1778200000000_CreateProjectTicketAttachments.ts @@ -0,0 +1,32 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateProjectTicketAttachments1778200000000 implements MigrationInterface { + name = 'CreateProjectTicketAttachments1778200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "billing_project_ticket_attachments" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ticket_id" uuid NOT NULL, + "file_name" character varying(255) NOT NULL, + "content_type" character varying(255) NOT NULL, + "byte_size" bigint NOT NULL, + "storage_key" character varying(512) NOT NULL, + "stored_file_id" uuid, + "created_by_user_id" uuid, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_billing_project_ticket_attachments" PRIMARY KEY ("id"), + CONSTRAINT "FK_billing_project_ticket_attachments_ticket" FOREIGN KEY ("ticket_id") REFERENCES "billing_project_tickets"("id") ON DELETE CASCADE, + CONSTRAINT "FK_billing_project_ticket_attachments_user" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL + ) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_billing_project_ticket_attachments_ticket" ON "billing_project_ticket_attachments" ("ticket_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "billing_project_ticket_attachments"`); + } +} diff --git a/apps/decabill/backend-billing-manager/src/typeorm.config.ts b/apps/decabill/backend-billing-manager/src/typeorm.config.ts index 281a8de8e..2251a6fad 100644 --- a/apps/decabill/backend-billing-manager/src/typeorm.config.ts +++ b/apps/decabill/backend-billing-manager/src/typeorm.config.ts @@ -42,6 +42,7 @@ import { ProjectTicketActivityEntity, ProjectTicketCommentEntity, ProjectTicketEntity, + ProjectTicketAttachmentEntity, ProjectTimeEntryEntity, ProviderPriceSnapshotEntity, ReservedHostnameEntity, @@ -144,6 +145,7 @@ export const typeormConfig: DataSourceOptions = { ProjectEntity, ProjectMilestoneEntity, ProjectTicketEntity, + ProjectTicketAttachmentEntity, ProjectTicketCommentEntity, ProjectTicketActivityEntity, ProjectTimeEntryEntity, diff --git a/docs/agenstra/features/README.md b/docs/agenstra/features/README.md index d87f22932..5ee687274 100644 --- a/docs/agenstra/features/README.md +++ b/docs/agenstra/features/README.md @@ -15,6 +15,7 @@ Agenstra provides a complete set of features for managing distributed AI agent i - **Version Control** Git operations directly from the web interface - **Web IDE** Monaco Editor integration for code editing - **Chat Interface** AI chat functionality with real-time responses +- **[File attachments](./file-attachments.md)** Attach/download files on tickets, knowledge pages, and chat context with read-only agent mirroring - **[Chat plan mode](./chat-plan-mode.md)** Explore-only OpenCode planning with refine and execute-into-chat - **Deployment** CI/CD pipeline management and deployment functionality - **Authentication** Multiple authentication methods with configurable user registration diff --git a/docs/agenstra/features/file-attachments.md b/docs/agenstra/features/file-attachments.md new file mode 100644 index 000000000..67743ef77 --- /dev/null +++ b/docs/agenstra/features/file-attachments.md @@ -0,0 +1,19 @@ +# File attachments + +Agenstra supports attachments on tickets, knowledge pages, and chat prompts. + +## Supported objects + +- Tickets: attachments appear below the ticket description and are returned in ticket list/detail DTOs. +- Knowledge pages: attachments appear below the page description and are returned in page/tree DTOs. +- Chat prompts and plan refinement: files can be uploaded from the context picker or dropped on the chat input. The message stores attachment metadata and renders a small downloadable card below the user message. + +## Storage and agent context + +Attachment bytes are stored with the shared file-storage abstraction under the `agenstraAttachments` scope. The database stores only metadata and a generated storage key; user supplied file names are sanitized and never used as path roots. + +When a chat prompt references tickets or pages, their attachments are resolved during context enrichment. Explicit chat attachments are resolved the same way. Resolved files are copied to each selected agent container under `/opt/agenstra/context/...`; the manager writes those files as root and hardens them to be readable but not writable by the runtime user. + +## Security + +All upload, list, and download operations resolve the owning workspace before file access. Downloads require the attachment metadata row and workspace access, so raw storage keys are not exposed as authorization credentials. diff --git a/docs/decabill/features/README.md b/docs/decabill/features/README.md index ece5e67ae..ee3e8b0ee 100644 --- a/docs/decabill/features/README.md +++ b/docs/decabill/features/README.md @@ -29,6 +29,7 @@ Decabill provides a complete set of capabilities for subscription billing, invoi - **CloudInit Configs** Admin-managed Docker templates for the custom service kind - **Projects** Customer-assigned work tracking with admin CRUD and billable time - **Project Board** Live ticket board with swimlanes and WebSocket updates +- **[Ticket attachments](./ticket-attachments.md)** Upload, list, download, and file-explorer integration for project ticket files - **OpenTelemetry** Optional Prometheus metrics and OTLP export (disabled by default) ## Features diff --git a/docs/decabill/features/ticket-attachments.md b/docs/decabill/features/ticket-attachments.md new file mode 100644 index 000000000..63c08d769 --- /dev/null +++ b/docs/decabill/features/ticket-attachments.md @@ -0,0 +1,14 @@ +# Project ticket attachments + +Decabill project tickets support file attachments below the ticket description. + +Attachments are stored through the shared file-storage abstraction in the `projectTicketAttachments` scope and registered in the existing `billing_stored_files` registry, including content hashes and signatures when signing is enabled. Files are visible in the admin File Explorer under `project/tickets/attachments/...`. + +## Security + +Attachment endpoints require project access in addition to PAT scopes: + +- `attachments:read` for listing/downloading. +- `attachments:write` for uploading/deleting metadata. + +The API always resolves the project and ticket first; download access is never granted by storage key alone. diff --git a/graph/graph.json b/graph/graph.json index 7660756bd..ffc029a9a 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-10T17:28:24.970Z", + "generatedAt": "2026-10-12T09:30:06.008Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -4752,6 +4752,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", "type": "controller", @@ -4950,6 +4959,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/attachment.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/attachment.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts", "type": "dto", @@ -5382,6 +5400,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agenstra-attachment.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agenstra-attachment.entity.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts", "type": "entity", @@ -5976,6 +6003,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts", "type": "service", @@ -8591,6 +8627,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts", "type": "controller", @@ -8627,6 +8672,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket-attachment.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket-attachment.dto.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket.dto.ts", "type": "dto", @@ -8672,6 +8726,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-attachment.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-attachment.entity.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-comment.entity.ts", "type": "entity", @@ -8807,6 +8870,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts", "type": "service", @@ -14060,6 +14132,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/agenstra/features/file-attachments.md", + "type": "doc", + "attrs": { + "path": "docs/agenstra/features/file-attachments.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/agenstra/features/file-management.md", "type": "doc", @@ -14684,6 +14764,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/decabill/features/ticket-attachments.md", + "type": "doc", + "attrs": { + "path": "docs/decabill/features/ticket-attachments.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/decabill/features/usage-meters.md", "type": "doc", @@ -15943,6 +16031,61 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/knowledge/{id}/attachments", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/knowledge/{id}/attachments", + "operationId": "listKnowledgePageAttachments", + "summary": "List knowledge page attachments", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/knowledge/{id}/attachments", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/knowledge/{id}/attachments", + "operationId": "uploadKnowledgePageAttachment", + "summary": "Upload knowledge page attachment", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/knowledge/{id}/attachments/{attachmentId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/knowledge/{id}/attachments/{attachmentId}", + "operationId": "deleteKnowledgePageAttachment", + "summary": "Delete knowledge page attachment metadata", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/attachments/chat", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/attachments/chat", + "operationId": "uploadChatAttachment", + "summary": "Upload chat context attachment", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/attachments/{id}/download", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/attachments/{id}/download", + "operationId": "downloadAttachment", + "summary": "Download attachment", + "specKind": "openapi" + } + }, { "id": "api:HTTP:POST:/knowledge/{id}/reorder", "type": "endpoint", @@ -16053,6 +16196,39 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/tickets/{id}/attachments", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/tickets/{id}/attachments", + "operationId": "listTicketAttachments", + "summary": "List ticket attachments", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/tickets/{id}/attachments", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/tickets/{id}/attachments", + "operationId": "uploadTicketAttachment", + "summary": "Upload ticket attachment", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/tickets/{id}/attachments/{attachmentId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/tickets/{id}/attachments/{attachmentId}", + "operationId": "deleteTicketAttachment", + "summary": "Delete ticket attachment metadata", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/tickets/{id}/activity", "type": "endpoint", @@ -17711,6 +17887,24 @@ "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:attachment.created", + "type": "webhook-event", + "attrs": { + "eventName": "attachment.created", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:attachment.deleted", + "type": "webhook-event", + "attrs": { + "eventName": "attachment.deleted", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, { "id": "webhook-event:agenstra-backend-feature-agent-controller:chat_message.created", "type": "webhook-event", @@ -20496,6 +20690,50 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/projects/{projectId}/tickets/{ticketId}/attachments", + "operationId": "listProjectTicketAttachments", + "summary": "List ticket attachments", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/projects/{projectId}/tickets/{ticketId}/attachments", + "operationId": "uploadProjectTicketAttachment", + "summary": "Upload ticket attachment", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}", + "operationId": "deleteProjectTicketAttachment", + "summary": "Delete ticket attachment metadata", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}/download", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}/download", + "operationId": "downloadProjectTicketAttachment", + "summary": "Download ticket attachment", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/projects/{projectId}/milestones", "type": "endpoint", @@ -21864,6 +22102,24 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:ticket.attachment.created", + "type": "webhook-event", + "attrs": { + "eventName": "ticket.attachment.created", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:ticket.attachment.deleted", + "type": "webhook-event", + "attrs": { + "eventName": "ticket.attachment.deleted", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:ticket.comment.created", "type": "webhook-event", @@ -26208,6 +26464,36 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-file-attachments", + "type": "concept", + "attrs": { + "title": "File attachments", + "docPath": "docs/agenstra/features/file-attachments.md", + "sectionAnchor": "file-attachments", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-supported-objects", + "type": "concept", + "attrs": { + "title": "Supported objects", + "docPath": "docs/agenstra/features/file-attachments.md", + "sectionAnchor": "supported-objects", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-storage-and-agent-context", + "type": "concept", + "attrs": { + "title": "Storage and agent context", + "docPath": "docs/agenstra/features/file-attachments.md", + "sectionAnchor": "storage-and-agent-context", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-file-management", "type": "concept", @@ -31998,6 +32284,16 @@ "domain": "decabill" } }, + { + "id": "concept:decabill-project-ticket-attachments", + "type": "concept", + "attrs": { + "title": "Project ticket attachments", + "docPath": "docs/decabill/features/ticket-attachments.md", + "sectionAnchor": "project-ticket-attachments", + "domain": "decabill" + } + }, { "id": "concept:decabill-usage-meters", "type": "concept", @@ -33605,6 +33901,11 @@ "to": "project:shared-backend-util-otel", "type": "depends_on" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "project:shared-backend-util-file-storage", + "type": "depends_on" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "project:shared-backend-util-queue", @@ -37420,6 +37721,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/admin-opencode-layer-files.controller.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", @@ -37530,6 +37836,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/agent-console-status.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/attachment.dto.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts", @@ -37770,6 +38081,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/update-client.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agenstra-attachment.entity.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts", @@ -38100,6 +38416,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/workspace-search-index.service.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts", @@ -39535,6 +39856,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-milestones.controller.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts", @@ -39555,6 +39881,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-milestone.dto.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket-attachment.dto.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket.dto.ts", @@ -39580,6 +39911,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-activity.entity.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-attachment.entity.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-comment.entity.ts", @@ -39655,6 +39991,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-milestones.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts", @@ -42830,6 +43171,31 @@ "to": "api:HTTP:GET:/knowledge/{id}/activity", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/knowledge/{id}/attachments", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/knowledge/{id}/attachments", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/knowledge/{id}/attachments/{attachmentId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/attachments/chat", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/attachments/{id}/download", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:POST:/knowledge/{id}/reorder", @@ -42880,6 +43246,21 @@ "to": "api:HTTP:POST:/tickets/{id}/comments", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/tickets/{id}/attachments", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/tickets/{id}/attachments", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/tickets/{id}/attachments/{attachmentId}", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:GET:/tickets/{id}/activity", @@ -43645,6 +44026,16 @@ "to": "webhook-event:agenstra-backend-feature-agent-controller:application.update_check_failed", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:attachment.created", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:attachment.deleted", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "webhook-event:agenstra-backend-feature-agent-controller:chat_message.created", @@ -45095,6 +45486,26 @@ "to": "api:HTTP:POST:/projects/{projectId}/tickets/{ticketId}/comments", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}/download", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/projects/{projectId}/milestones", @@ -45810,6 +46221,16 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:supplier_profile.updated", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:ticket.attachment.created", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:ticket.attachment.deleted", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:ticket.comment.created", @@ -48585,6 +49006,26 @@ "to": "concept:agenstra-events", "type": "contains" }, + { + "from": "file:docs/agenstra/features/file-attachments.md", + "to": "concept:agenstra-file-attachments", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/file-attachments.md", + "to": "concept:agenstra-supported-objects", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/file-attachments.md", + "to": "concept:agenstra-storage-and-agent-context", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/file-attachments.md", + "to": "concept:agenstra-security", + "type": "contains" + }, { "from": "file:docs/agenstra/features/file-management.md", "to": "concept:agenstra-file-management", @@ -52600,6 +53041,16 @@ "to": "concept:decabill-related", "type": "contains" }, + { + "from": "file:docs/decabill/features/ticket-attachments.md", + "to": "concept:decabill-project-ticket-attachments", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/ticket-attachments.md", + "to": "concept:decabill-security", + "type": "contains" + }, { "from": "file:docs/decabill/features/usage-meters.md", "to": "concept:decabill-usage-meters", @@ -54925,6 +55376,36 @@ "to": "api:HTTP:GET:/knowledge/{id}/activity", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", + "to": "api:HTTP:GET:/knowledge/{id}/attachments", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/knowledge/{id}/attachments", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", + "to": "api:HTTP:POST:/knowledge/{id}/attachments", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:POST:/knowledge/{id}/attachments", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", + "to": "api:HTTP:DELETE:/knowledge/{id}/attachments/{attachmentId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:DELETE:/knowledge/{id}/attachments/{attachmentId}", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", "to": "api:HTTP:POST:/knowledge/{id}/reorder", @@ -55115,6 +55596,36 @@ "to": "api:HTTP:POST:/tickets/{id}/comments", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts", + "to": "api:HTTP:GET:/tickets/{id}/attachments", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/tickets/{id}/attachments", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts", + "to": "api:HTTP:POST:/tickets/{id}/attachments", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:POST:/tickets/{id}/attachments", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts", + "to": "api:HTTP:DELETE:/tickets/{id}/attachments/{attachmentId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:DELETE:/tickets/{id}/attachments/{attachmentId}", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts", "to": "api:HTTP:GET:/tickets/{id}/activity", @@ -58830,6 +59341,46 @@ "to": "api:HTTP:POST:/projects/{projectId}/tickets/{ticketId}/comments", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts", + "to": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts", + "to": "api:HTTP:POST:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/projects/{projectId}/tickets/{ticketId}/attachments", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts", + "to": "api:HTTP:DELETE:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:DELETE:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts", + "to": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}/download", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}/download", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts", "to": "api:HTTP:GET:/projects/{projectId}/milestones", @@ -60910,6 +61461,21 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-layer-files.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/client-users.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", @@ -61290,6 +61856,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/workspace-search-index.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/knowledge-board.gateway.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", @@ -61405,6 +61976,21 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", @@ -63010,6 +63596,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-milestones.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts", @@ -63135,6 +63726,31 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-board-summary.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/repositories/projects.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/repositories/project-tickets.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/repositories/project-ticket-activities.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/repositories/projects.repository.ts", @@ -63180,6 +63796,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/search/billing-search-index.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-entries.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/repositories/projects.repository.ts", @@ -66570,6 +67191,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", @@ -66650,6 +67276,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts", @@ -67235,6 +67866,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-time-entries.controller.ts", @@ -67785,6 +68421,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-entries.service.ts", @@ -70170,6 +70811,26 @@ "to": "api:HTTP:GET:/knowledge/{id}/activity", "type": "calls" }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", + "to": "api:HTTP:GET:/knowledge/{id}/attachments", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", + "to": "api:HTTP:POST:/knowledge/{id}/attachments", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", + "to": "api:HTTP:DELETE:/knowledge/{id}/attachments/{attachmentId}", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", + "to": "api:HTTP:GET:/attachments/{id}/download", + "type": "calls" + }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts", "to": "api:HTTP:GET:/opencode/providers", @@ -70400,6 +71061,31 @@ "to": "api:HTTP:GET:/tickets/{id}/activity", "type": "calls" }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts", + "to": "api:HTTP:GET:/tickets/{id}/attachments", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts", + "to": "api:HTTP:POST:/tickets/{id}/attachments", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts", + "to": "api:HTTP:DELETE:/tickets/{id}/attachments/{attachmentId}", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts", + "to": "api:HTTP:GET:/attachments/{id}/download", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts", + "to": "api:HTTP:POST:/attachments/chat", + "type": "calls" + }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts", "to": "api:HTTP:POST:/tickets/{id}/body-generation-sessions", @@ -77085,6 +77771,21 @@ "to": "api:HTTP:POST:/admin/billing/supplier-profiles/{supplierId}/contracts/get-or-create", "type": "documents" }, + { + "from": "concept:decabill-project-ticket-attachments", + "to": "api:HTTP:POST:/auth/register", + "type": "documents" + }, + { + "from": "concept:decabill-project-ticket-attachments", + "to": "api:HTTP:GET:/tickets", + "type": "documents" + }, + { + "from": "concept:decabill-project-ticket-attachments", + "to": "api:HTTP:POST:/tickets", + "type": "documents" + }, { "from": "concept:decabill-catalog", "to": "api:HTTP:GET:/meters", @@ -79020,6 +79721,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "file:docs/agenstra/features/file-attachments.md", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "file:docs/agenstra/features/file-management.md", "to": "domain:agenstra", @@ -79410,6 +80116,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "file:docs/decabill/features/ticket-attachments.md", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "file:docs/decabill/features/usage-meters.md", "to": "domain:decabill", @@ -80990,6 +81701,21 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-file-attachments", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-supported-objects", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-storage-and-agent-context", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-file-management", "to": "domain:agenstra", @@ -83885,6 +84611,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "concept:decabill-project-ticket-attachments", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "concept:decabill-usage-meters", "to": "domain:decabill", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml index f0900b1fd..be9121c60 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml @@ -673,7 +673,8 @@ components: When event is "closeTerminal", the payload should contain {sessionId: string}. When event is "enhanceChat", the payload should contain { message: string, correlationId: string, model?: string }. When event is "generateTicketBody", the payload should contain { title: string, correlationId: string, model?: string }. - When event is "chat", the payload may include { message, correlationId?, model?, responseMode?: "single"|"stream"|"sync", ephemeral?: boolean, continue?: boolean, resumeSessionSuffix?: string, contextInjection?: { includeWorkspace?: boolean, environmentIds?: string[], ticketShas?: string[], knowledgeShas?: string[], autoEnrichmentEnabled?: boolean } }. + When event is "chat", the payload may include { message, correlationId?, model?, responseMode?: "single"|"stream"|"sync", ephemeral?: boolean, continue?: boolean, resumeSessionSuffix?: string, contextInjection?: { includeWorkspace?: boolean, environmentIds?: string[], ticketShas?: string[], knowledgeShas?: string[], attachmentIds?: string[], autoEnrichmentEnabled?: boolean } }. + The controller resolves ticket/page/chat attachments and forwards read-only agentPath metadata to the agent-manager. Response events forwarded from the agent-manager include "containerStats" (payload has status.running and optional stats), "environmentProgress" (environment create/update progress, sent without agent login), and "opencodeConfigSynced" (data.agentId identifies a successfully applied effective config; refresh cached models and slash commands). agentId: type: string diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml index 34932f6cf..504a81f30 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -1300,6 +1300,96 @@ paths: type: array items: $ref: '#/components/schemas/KnowledgePageActivityResponseDto' + /knowledge/{id}/attachments: + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + get: + summary: List knowledge page attachments + description: Requires `attachments:read`; caller must be able to read the page workspace. + operationId: listKnowledgePageAttachments + responses: + '200': + description: Attachment list + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/AttachmentResponseDto' } + post: + summary: Upload knowledge page attachment + description: Requires `attachments:write`; multipart field name is `file`. + operationId: uploadKnowledgePageAttachment + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + responses: + '201': + description: Uploaded attachment + content: + application/json: + schema: { $ref: '#/components/schemas/AttachmentResponseDto' } + /knowledge/{id}/attachments/{attachmentId}: + delete: + summary: Delete knowledge page attachment metadata + description: Requires `attachments:write`; caller must be able to write the page workspace. + operationId: deleteKnowledgePageAttachment + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + - in: path + name: attachmentId + required: true + schema: { type: string, format: uuid } + responses: + '204': { description: Deleted } + /attachments/chat: + post: + summary: Upload chat context attachment + description: Requires `attachments:write`; multipart field name is `file`. The returned id can be sent in chat `contextInjection.attachmentIds`. + operationId: uploadChatAttachment + parameters: + - in: query + name: clientId + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + responses: + '201': + description: Uploaded attachment + content: + application/json: + schema: { $ref: '#/components/schemas/AttachmentResponseDto' } + /attachments/{id}/download: + get: + summary: Download attachment + description: Requires `attachments:read`; caller must be able to read the attachment workspace. + operationId: downloadAttachment + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': { description: Attachment bytes } /knowledge/{id}/reorder: post: summary: Reorder knowledge node @@ -1511,6 +1601,59 @@ paths: application/json: schema: $ref: '#/components/schemas/TicketCommentResponseDto' + /tickets/{id}/attachments: + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + get: + summary: List ticket attachments + description: Requires `attachments:read`; caller must be able to read the ticket workspace. + operationId: listTicketAttachments + responses: + '200': + description: Attachment list + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/AttachmentResponseDto' } + post: + summary: Upload ticket attachment + description: Requires `attachments:write`; multipart field name is `file`. + operationId: uploadTicketAttachment + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + responses: + '201': + description: Uploaded attachment + content: + application/json: + schema: { $ref: '#/components/schemas/AttachmentResponseDto' } + /tickets/{id}/attachments/{attachmentId}: + delete: + summary: Delete ticket attachment metadata + description: Requires `attachments:write`; caller must be able to write the ticket workspace. + operationId: deleteTicketAttachment + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + - in: path + name: attachmentId + required: true + schema: { type: string, format: uuid } + responses: + '204': { description: Deleted } /tickets/{id}/activity: get: summary: List ticket activity @@ -7826,6 +7969,37 @@ components: description: | Sums of open/done checkboxes on all descendant tickets (direct and nested), excluding this ticket's body. $ref: '#/components/schemas/TicketMarkdownCheckboxTaskCountsDto' + AttachmentResponseDto: + type: object + required: + [ + id, + clientId, + ownerType, + ownerId, + fileName, + contentType, + byteSize, + contentSha256, + downloadUrl, + createdAt, + ] + properties: + id: { type: string, format: uuid } + clientId: { type: string, format: uuid } + ownerType: { type: string, enum: [ticket, knowledge_page, chat] } + ownerId: { type: string, format: uuid } + fileName: { type: string } + contentType: { type: string } + byteSize: { type: integer } + contentSha256: { type: string } + downloadUrl: { type: string } + agentPath: + type: string + nullable: true + description: Read-only absolute path inside agent containers when mirrored into chat context. + createdByUserId: { type: string, format: uuid, nullable: true } + createdAt: { type: string, format: date-time } TicketShasDto: type: object required: [short, long] @@ -7896,6 +8070,10 @@ components: format: date-time tasks: $ref: '#/components/schemas/TicketTasksDto' + attachments: + type: array + items: + $ref: '#/components/schemas/AttachmentResponseDto' children: type: array items: @@ -9046,6 +9224,9 @@ components: title: { type: string } content: { type: string, nullable: true } sortOrder: { type: integer } + attachments: + type: array + items: { $ref: '#/components/schemas/AttachmentResponseDto' } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } children: diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts index f9132ed95..6afb0c739 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts @@ -81,6 +81,8 @@ export * from './lib/entities/external-import.enums'; export * from './lib/entities/client-agent-autonomy.entity'; export * from './lib/entities/knowledge-node-embedding.entity'; export * from './lib/entities/knowledge-node.entity'; +export * from './lib/entities/agenstra-attachment.entity'; +export * from './lib/entities/attachment.enums'; export * from './lib/entities/knowledge-page-activity.entity'; export * from './lib/entities/knowledge-relation.entity'; export * from './lib/entities/provisioning-reference.entity'; @@ -98,6 +100,7 @@ export * from './lib/entities/ticket.entity'; export * from './lib/entities/user-chat-session-read-state.entity'; export * from './lib/entities/user-environment-read-state.entity'; export * from './lib/entities/ticket.enums'; +export * from './lib/dto/attachment.dto'; export * from './lib/entities/statistics-agent.entity'; export * from './lib/entities/statistics-chat-filter-drop.entity'; export * from './lib/entities/statistics-chat-filter-flag.entity'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts index 93b55cc20..13154f678 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts @@ -12,6 +12,8 @@ describe('AGENSTRA_PAT_SCOPES', () => { 'clients:write', 'tickets:read', 'tickets:write', + 'attachments:read', + 'attachments:write', 'filter_rules:write', 'agents:read', 'agents:write', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts index 4a05448f6..980db1d70 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts @@ -11,6 +11,8 @@ export const AGENSTRA_PAT_SCOPES = [ 'tickets:write', 'knowledge:read', 'knowledge:write', + 'attachments:read', + 'attachments:write', 'filter_rules:write', 'agents:read', 'agents:write', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts new file mode 100644 index 000000000..b685c1858 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/attachments.controller.ts @@ -0,0 +1,175 @@ +import { + ClientUsersRepository, + RequireScopes, + ensureClientAccess, + getUserFromRequest, + type RequestWithUser, +} from '@forepath/identity/backend'; +import { + Controller, + Delete, + Get, + Header, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Query, + Req, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; + +import { AgenstraAttachmentOwnerType } from '../entities/attachment.enums'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { AgenstraAttachmentsService, type UploadedAttachmentFile } from '../services/agenstra-attachments.service'; + +function contentDispositionAttachment(filename: string): string { + const fallback = filename.replace(/[^A-Za-z0-9._-]/g, '_') || 'attachment'; + const encoded = encodeURIComponent(filename).replace(/['()]/g, escape).replace(/\*/g, '%2A'); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} + +@Controller() +export class AttachmentsController { + constructor( + private readonly attachments: AgenstraAttachmentsService, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + ) {} + + @Post('tickets/:id/attachments') + @RequireScopes('attachments:write') + @UseInterceptors(FileInterceptor('file')) + async uploadTicket( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: UploadedAttachmentFile, + @Req() req?: RequestWithUser, + ) { + const clientId = await this.attachments.getTicketClientId(id); + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + const dto = await this.attachments.uploadForTicket( + id, + file, + getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null, + ); + return dto; + } + + @Get('tickets/:id/attachments') + @RequireScopes('attachments:read') + async listTicket(@Param('id', ParseUUIDPipe) id: string, @Req() req?: RequestWithUser) { + await ensureClientAccess( + this.clientsRepository, + this.clientUsersRepository, + await this.attachments.getTicketClientId(id), + req, + ); + const rows = await this.attachments.listForOwner(AgenstraAttachmentOwnerType.TICKET, id); + return rows; + } + + @Delete('tickets/:id/attachments/:attachmentId') + @RequireScopes('attachments:write') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteTicket( + @Param('id', ParseUUIDPipe) id: string, + @Param('attachmentId', ParseUUIDPipe) attachmentId: string, + @Req() req?: RequestWithUser, + ) { + const row = await this.attachments.getForOwner(AgenstraAttachmentOwnerType.TICKET, id, attachmentId); + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, row.clientId, req); + await this.attachments.deleteForOwner( + AgenstraAttachmentOwnerType.TICKET, + id, + attachmentId, + getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null, + ); + } + + @Post('knowledge/:id/attachments') + @RequireScopes('attachments:write') + @UseInterceptors(FileInterceptor('file')) + async uploadPage( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: UploadedAttachmentFile, + @Req() req?: RequestWithUser, + ) { + const clientId = await this.attachments.getKnowledgePageClientId(id); + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + const dto = await this.attachments.uploadForKnowledgePage( + id, + file, + getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null, + ); + return dto; + } + + @Get('knowledge/:id/attachments') + @RequireScopes('attachments:read') + async listPage(@Param('id', ParseUUIDPipe) id: string, @Req() req?: RequestWithUser) { + await ensureClientAccess( + this.clientsRepository, + this.clientUsersRepository, + await this.attachments.getKnowledgePageClientId(id), + req, + ); + const rows = await this.attachments.listForOwner(AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE, id); + return rows; + } + + @Delete('knowledge/:id/attachments/:attachmentId') + @RequireScopes('attachments:write') + @HttpCode(HttpStatus.NO_CONTENT) + async deletePage( + @Param('id', ParseUUIDPipe) id: string, + @Param('attachmentId', ParseUUIDPipe) attachmentId: string, + @Req() req?: RequestWithUser, + ) { + const row = await this.attachments.getForOwner(AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE, id, attachmentId); + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, row.clientId, req); + await this.attachments.deleteForOwner( + AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE, + id, + attachmentId, + getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null, + ); + } + + @Post('attachments/chat') + @RequireScopes('attachments:write') + @UseInterceptors(FileInterceptor('file')) + async uploadChat( + @Query('clientId', ParseUUIDPipe) clientId: string, + @UploadedFile() file: UploadedAttachmentFile, + @Req() req?: RequestWithUser, + ) { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + return await this.attachments.uploadForChat( + clientId, + file, + getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null, + ); + } + + @Get('attachments/:id/download') + @RequireScopes('attachments:read') + async download( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: RequestWithUser, + @Res({ passthrough: true }) res: Response, + ) { + const metadata = await this.attachments.getById(id); + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, metadata.clientId, req); + const result = await this.attachments.readForDownload(id); + res.setHeader('Content-Type', result.meta.contentType); + res.setHeader('Content-Length', String(result.buffer.length)); + res.setHeader('Content-Disposition', contentDispositionAttachment(result.meta.fileName)); + return new StreamableFile(result.buffer); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/attachment.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/attachment.dto.ts new file mode 100644 index 000000000..21b3022e0 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/attachment.dto.ts @@ -0,0 +1,15 @@ +export class AttachmentResponseDto { + id!: string; + clientId!: string; + ownerType!: string; + ownerId!: string; + fileName!: string; + contentType!: string; + byteSize!: number; + contentSha256!: string; + downloadUrl!: string; + /** Absolute read-only path where this file is mirrored inside agent containers when used as chat context. */ + agentPath?: string | null; + createdByUserId?: string | null; + createdAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/knowledge/knowledge-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/knowledge/knowledge-response.dto.ts index 608828529..7a0159cec 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/knowledge/knowledge-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/knowledge/knowledge-response.dto.ts @@ -3,6 +3,7 @@ import { KnowledgeRelationSourceType, KnowledgeRelationTargetType, } from '../../entities/knowledge-node.enums'; +import { AttachmentResponseDto } from '../attachment.dto'; export class KnowledgeNodeShasDto { short!: string; @@ -18,6 +19,7 @@ export class KnowledgeNodeResponseDto { title!: string; content?: string | null; sortOrder!: number; + attachments?: AttachmentResponseDto[]; createdAt!: Date; updatedAt!: Date; children?: KnowledgeNodeResponseDto[]; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts index 281825496..84d589286 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts @@ -1,6 +1,7 @@ import { TicketPriority, TicketStatus } from '../../entities/ticket.enums'; import { TicketActivityResponseDto } from './ticket-activity-response.dto'; +import { AttachmentResponseDto } from '../attachment.dto'; /** Checkbox counts aggregated from all descendant tickets' descriptions (not this ticket). */ export class TicketTasksChildrenDto { @@ -41,6 +42,7 @@ export class TicketResponseDto { createdAt!: Date; updatedAt!: Date; tasks!: TicketTasksDto; + attachments?: AttachmentResponseDto[]; children?: TicketResponseDto[]; } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agenstra-attachment.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agenstra-attachment.entity.ts new file mode 100644 index 000000000..9e46a0751 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agenstra-attachment.entity.ts @@ -0,0 +1,56 @@ +import { createHash } from 'crypto'; + +import { ClientEntity, UserEntity } from '@forepath/identity/backend'; +import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; + +import { AgenstraAttachmentOwnerType } from './attachment.enums'; + +@Entity('agenstra_attachments') +@Index('IDX_agenstra_attachments_owner', ['ownerType', 'ownerId']) +@Index('IDX_agenstra_attachments_client', ['clientId']) +export class AgenstraAttachmentEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'client_id' }) + clientId!: string; + + @ManyToOne(() => ClientEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'client_id' }) + client!: ClientEntity; + + @Column({ type: 'varchar', length: 32, name: 'owner_type' }) + ownerType!: AgenstraAttachmentOwnerType; + + @Column({ type: 'uuid', name: 'owner_id' }) + ownerId!: string; + + @Column({ type: 'varchar', length: 255, name: 'file_name' }) + fileName!: string; + + @Column({ type: 'varchar', length: 255, name: 'content_type' }) + contentType!: string; + + @Column({ type: 'bigint', name: 'byte_size' }) + byteSize!: string; + + @Column({ type: 'varchar', length: 512, name: 'storage_key' }) + storageKey!: string; + + @Column({ type: 'varchar', length: 64, name: 'content_sha256' }) + contentSha256!: string; + + @Column({ type: 'uuid', nullable: true, name: 'created_by_user_id' }) + createdByUserId?: string | null; + + @ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'created_by_user_id' }) + createdByUser?: UserEntity | null; + + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) + createdAt!: Date; + + static contentSha256(content: Buffer): string { + return createHash('sha256').update(content).digest('hex'); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/attachment.enums.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/attachment.enums.ts new file mode 100644 index 000000000..7ee91caa9 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/attachment.enums.ts @@ -0,0 +1,5 @@ +export enum AgenstraAttachmentOwnerType { + TICKET = 'ticket', + KNOWLEDGE_PAGE = 'knowledge_page', + CHAT = 'chat', +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts index 59f7a8265..52d9604ad 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts @@ -7,6 +7,15 @@ export type ChatPlanContextInjectionJson = { includeWorkspace?: boolean; environmentIds?: string[]; autoEnrichmentEnabled?: boolean; + attachmentIds?: string[]; + attachmentContexts?: Array<{ + id: string; + fileName: string; + contentType: string; + byteSize: number; + agentPath: string; + downloadUrl: string; + }>; ticketShas?: string[]; ticketContexts?: string[]; knowledgeShas?: string[]; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/knowledge-node.enums.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/knowledge-node.enums.ts index 49c302bbf..491e387fc 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/knowledge-node.enums.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/knowledge-node.enums.ts @@ -31,4 +31,6 @@ export enum KnowledgeActionType { DUPLICATED = 'DUPLICATED', RELATION_ADDED = 'RELATION_ADDED', RELATION_REMOVED = 'RELATION_REMOVED', + ATTACHMENT_ADDED = 'ATTACHMENT_ADDED', + ATTACHMENT_REMOVED = 'ATTACHMENT_REMOVED', } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts index 895b86a8c..b8a960732 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts @@ -36,6 +36,8 @@ export enum TicketActionType { PARENT_CHANGED = 'PARENT_CHANGED', DELETED = 'DELETED', COMMENT_ADDED = 'COMMENT_ADDED', + ATTACHMENT_ADDED = 'ATTACHMENT_ADDED', + ATTACHMENT_REMOVED = 'ATTACHMENT_REMOVED', CONTENT_APPLIED_FROM_AI = 'CONTENT_APPLIED_FROM_AI', BODY_GENERATION_STARTED = 'BODY_GENERATION_STARTED', PROTOTYPE_PROMPT_GENERATED = 'PROTOTYPE_PROMPT_GENERATED', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts index 0060498a1..dd88c9ff5 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts @@ -10,6 +10,7 @@ import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity import { ClientsRepository } from '../repositories/clients.repository'; import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; import { AgentConsoleStatusService } from '../services/agent-console-status.service'; +import { AgenstraAttachmentsService } from '../services/agenstra-attachments.service'; import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; @@ -184,6 +185,7 @@ describe('ClientsGateway', () => { purgeAgent: jest.fn().mockResolvedValue(undefined), getStatus: jest.fn().mockReturnValue({ status: 'missing', docCount: 0 }), }; + const mockAttachmentsService = { resolveContextAttachments: jest.fn().mockResolvedValue([]) }; const createMockSocket = (id = 'socket-1', withUserInfo = true) => { const emitted: Record[] = []; const socket = { @@ -252,6 +254,7 @@ describe('ClientsGateway', () => { provide: WorkspaceSearchIndexService, useValue: mockWorkspaceSearchIndex, }, + { provide: AgenstraAttachmentsService, useValue: mockAttachmentsService }, ], }).compile(); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index 2a12852cf..f701826aa 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -30,6 +30,7 @@ import { AgenstraNotificationPublisher } from '../notifications/agenstra-notific import { ClientsRepository } from '../repositories/clients.repository'; import { WorkspaceSearchIndexService } from '../search/workspace-search-index.service'; import { AgentConsoleStatusService } from '../services/agent-console-status.service'; +import { AgenstraAttachmentsService } from '../services/agenstra-attachments.service'; import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; @@ -108,6 +109,15 @@ interface ForwardPayload { interface ContextInjectionPayload { includeWorkspace?: boolean; environmentIds?: string[]; + attachmentIds?: string[]; + attachmentContexts?: Array<{ + id: string; + fileName: string; + contentType: string; + byteSize: number; + agentPath: string; + downloadUrl: string; + }>; ticketShas?: string[]; ticketContexts?: string[]; knowledgeShas?: string[]; @@ -183,6 +193,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat private readonly agentConsoleStatusService: AgentConsoleStatusService, private readonly notificationPublisher: AgenstraNotificationPublisher, private readonly workspaceSearchIndex: WorkspaceSearchIndexService, + private readonly attachmentsService: AgenstraAttachmentsService, ) {} afterInit(server: Server): void { @@ -1096,7 +1107,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat try { const userInfo = (socket as Socket & { data?: { userInfo?: Parameters[0] } }) .data?.userInfo; - const enrichedPayload = (await this.enrichForwardPayloadWithTicketContext(clientId, { + const enrichedPayload = (await this.enrichForwardPayloadWithTicketContext(clientId, agentId, { message, contextInjection: data.contextInjection, })) as { @@ -1170,7 +1181,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat let contextInjection: ChatPlanContextInjectionJson | undefined; if (data.contextInjection) { - const enrichedPayload = (await this.enrichForwardPayloadWithTicketContext(clientId, { + const enrichedPayload = (await this.enrichForwardPayloadWithTicketContext(clientId, plan.agentId, { message, contextInjection: data.contextInjection, })) as { @@ -1356,7 +1367,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat } const agentId = data?.agentId; - const payloadWithContext = await this.enrichForwardPayloadWithTicketContext(clientId, payload); + const payloadWithContext = await this.enrichForwardPayloadWithTicketContext(clientId, agentId, payload); let loggedIn = this.loggedInAgentsBySocket.get(socket.id); if (!loggedIn) { @@ -1618,7 +1629,11 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat } } - private async enrichForwardPayloadWithTicketContext(clientId: string, payload: unknown): Promise { + private async enrichForwardPayloadWithTicketContext( + clientId: string, + agentId: string | undefined, + payload: unknown, + ): Promise { if (!payload || typeof payload !== 'object') { return payload; } @@ -1630,6 +1645,10 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat return payload; } + const attachmentIds = Array.from( + new Set((contextInjection.attachmentIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), + ); + const promptForAutoContext = ( (typed as { message?: string; title?: string }).message ?? (typed as { message?: string; title?: string }).title ?? @@ -1639,13 +1658,13 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat enabledGlobal: workspaceAutoEnrichEnabledGlobal, vectorMaxCosineDistance: workspaceAutoEnrichVectorMaxCosineDistance, } = await this.resolveWorkspaceAutoEnrichSettings(clientId); - const resolvedContextInjection = await this.autoContextResolverService.resolve({ + const resolvedContextInjection = (await this.autoContextResolverService.resolve({ clientId, prompt: promptForAutoContext, contextInjection, workspaceAutoEnrichEnabledGlobal, workspaceAutoEnrichVectorMaxCosineDistance, - }); + })) as ContextInjectionPayload; const ticketShas = Array.from( new Set((resolvedContextInjection.ticketShas ?? []).map((sha) => sha.trim()).filter((sha) => sha.length > 0)), ); @@ -1669,7 +1688,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat new Set((resolvedContextInjection.knowledgeShas ?? []).map((sha) => sha.trim()).filter((sha) => sha.length > 0)), ); - if (ticketShas.length === 0 && knowledgeShas.length === 0) { + if (ticketShas.length === 0 && knowledgeShas.length === 0 && attachmentIds.length === 0) { return payload; } @@ -1690,11 +1709,38 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat } } + let attachmentContexts: NonNullable = []; + + if (agentId && (attachmentIds.length > 0 || ticketShas.length > 0 || knowledgeShas.length > 0)) { + try { + attachmentContexts = await this.attachmentsService.resolveContextAttachments({ + clientId, + agentId, + attachmentIds, + ticketShas, + knowledgeShas, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.warn(`Failed to resolve attachment context for client ${clientId}: ${message}`); + } + } + return { ...typed, contextInjection: { ...resolvedContextInjection, ticketShas, + attachmentIds, + attachmentContexts: Array.from( + new Map( + [...attachmentContexts, ...(resolvedContextInjection.attachmentContexts ?? [])].map((attachment) => [ + attachment.id, + attachment, + ]), + ).values(), + ), ticketContexts, knowledgeShas, knowledgeContexts: Array.from( diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index 53b891e6f..c13ffa405 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -19,6 +19,7 @@ import { KEYCLOAK_CONNECT_OPTIONS, KEYCLOAK_INSTANCE } from 'nest-keycloak-conne import { ClientsController } from '../controllers/clients.controller'; import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; +import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity'; import { AtlassianSiteConnectionEntity } from '../entities/atlassian-site-connection.entity'; import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; import { ExternalImportConfigEntity } from '../entities/external-import-config.entity'; @@ -241,6 +242,8 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(ChatPlanEntity)) .useValue(mockTicketRepository) + .overrideProvider(getRepositoryToken(AgenstraAttachmentEntity)) + .useValue(mockRepository) .overrideProvider(getRepositoryToken(ClientAgentAutonomyEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(KnowledgeNodeEntity)) diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts index 52fa4d1e4..902783b02 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -3,6 +3,7 @@ import { registerDynamicProviders, } from '@forepath/shared/backend/util-dynamic-provider-registry'; import { RedisCacheModule } from '@forepath/shared/backend/util-redis-cache'; +import { FileStorageModule } from '@forepath/shared/backend/util-file-storage'; import { ClientAgentCredentialEntity, ClientAgentCredentialsRepository, @@ -27,6 +28,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { KeycloakConnectModule } from 'nest-keycloak-connect'; import { ClientAgentAutonomyDirectoryController } from '../controllers/client-agent-autonomy-directory.controller'; +import { AttachmentsController } from '../controllers/attachments.controller'; import { ClientAgentAutonomyController } from '../controllers/client-agent-autonomy.controller'; import { ClientStatisticsController } from '../controllers/client-statistics.controller'; import { ClientsAgentAutomationProxyController } from '../controllers/clients-agent-automation-proxy.controller'; @@ -52,6 +54,7 @@ import { ClientOpencodeConfigEntity } from '../entities/client-opencode-config.e import { GlobalOpencodeConfigEntity } from '../entities/global-opencode-config.entity'; import { OpencodeMcpServerEntity } from '../entities/opencode-mcp-server.entity'; import { ChatPlanEntity } from '../entities/chat-plan.entity'; +import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity'; import { OpencodeProviderEntity } from '../entities/opencode-provider.entity'; import { OpencodeLayerFileEntity } from '../entities/opencode-layer-file.entity'; import { OpencodeLayerFileSyncTargetEntity } from '../entities/opencode-layer-file-sync-target.entity'; @@ -87,6 +90,7 @@ import { UserChatSessionReadStateRepository } from '../repositories/user-chat-se import { UserEnvironmentReadStateRepository } from '../repositories/user-environment-read-state.repository'; import { AgentConsoleStatusRealtimeService } from '../services/agent-console-status-realtime.service'; import { AgentConsoleStatusService } from '../services/agent-console-status.service'; +import { AgenstraAttachmentsService } from '../services/agenstra-attachments.service'; import { AgenstraMetricsCollectorService } from '../services/agenstra-metrics-collector.service'; import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { AutonomousRunOrchestratorService } from '../services/autonomous-run-orchestrator.service'; @@ -162,6 +166,7 @@ const authMethod = getAuthenticationMethod(); TicketAutomationLeaseEntity, TicketAutomationRunStepEntity, ChatPlanEntity, + AgenstraAttachmentEntity, ClientAgentAutonomyEntity, KnowledgeNodeEntity, KnowledgeNodeEmbeddingEntity, @@ -179,6 +184,7 @@ const authMethod = getAuthenticationMethod(); ]), BullModule.registerQueue({ name: AGENSTRA_CONTROLLER_QUEUE_NAME }), RedisCacheModule, + FileStorageModule, AgenstraSearchModule, AgenstraNotificationsModule, AgenstraUpdatesModule, @@ -196,6 +202,7 @@ const authMethod = getAuthenticationMethod(); ClientStatisticsController, StatisticsController, TicketsController, + AttachmentsController, KnowledgeTreeController, TicketAutomationController, ChatPlanController, @@ -214,6 +221,7 @@ const authMethod = getAuthenticationMethod(); providers: [ AgenstraMetricsCollectorService, ClientsService, + AgenstraAttachmentsService, TicketsService, KnowledgeTreeService, AutoContextResolverService, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts index 7b8ae95d6..be8dc11e6 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts @@ -25,6 +25,8 @@ describe('AGENSTRA_NOTIFICATION_EVENTS', () => { 'chat_session.updated', 'chat_session.deleted', 'ticket.comment.created', + 'attachment.created', + 'attachment.deleted', 'application.update_available', 'application.update_check_failed', 'application.instance_outdated', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts index 4cbdcb636..6984bc04c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts @@ -9,6 +9,8 @@ export const AGENSTRA_NOTIFICATION_EVENTS = [ 'ticket.updated', 'ticket.deleted', 'ticket.comment.created', + 'attachment.created', + 'attachment.deleted', 'chat_message.created', 'agent.acp.session_failed', 'agent.acp.permission_denied', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts index c73e82a43..44a7701ad 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts @@ -9,6 +9,7 @@ import type { import type { FilterRuleResponseDto } from '../dto/filter-rules/filter-rule-response.dto'; import type { ClientResponseDto } from '../dto/client-response.dto'; +import type { AttachmentResponseDto } from '../dto/attachment.dto'; import type { TicketCommentResponseDto } from '../dto/tickets/ticket-comment-response.dto'; import type { TicketResponseDto } from '../dto/tickets/ticket-response.dto'; @@ -97,6 +98,10 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli this.publish('ticket.comment.created', this.toTicketCommentPayload(comment), clientId); } + publishAttachment(type: 'attachment.created' | 'attachment.deleted', attachment: AttachmentResponseDto): void { + this.publish(type, this.toAttachmentPayload(attachment), attachment.clientId); + } + publishFilterRule( type: 'filter_rule.created' | 'filter_rule.updated' | 'filter_rule.deleted', rule: FilterRuleResponseDto, @@ -216,6 +221,21 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli }; } + private toAttachmentPayload(attachment: AttachmentResponseDto): Record { + return { + id: attachment.id, + clientId: attachment.clientId, + ownerType: attachment.ownerType, + ownerId: attachment.ownerId, + fileName: attachment.fileName, + contentType: attachment.contentType, + byteSize: attachment.byteSize, + contentSha256: attachment.contentSha256, + createdByUserId: attachment.createdByUserId ?? null, + createdAt: this.toIsoString(attachment.createdAt), + }; + } + private toFilterRulePayload(rule: FilterRuleResponseDto): Record { return { id: rule.id, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.spec.ts new file mode 100644 index 000000000..8802fd34a --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.spec.ts @@ -0,0 +1,108 @@ +import { BadRequestException } from '@nestjs/common'; + +import { AgenstraAttachmentOwnerType } from '../entities/attachment.enums'; +import { KnowledgeNodeType } from '../entities/knowledge-node.enums'; +import { AgenstraAttachmentsService } from './agenstra-attachments.service'; + +describe('AgenstraAttachmentsService', () => { + const attachmentRepo = { + save: jest.fn(async (row) => ({ ...row, createdAt: new Date('2026-01-01T00:00:00.000Z') })), + create: jest.fn((row) => row), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + const ticketRepo = { findOne: jest.fn(), createQueryBuilder: jest.fn() }; + const knowledgeRepo = { findOne: jest.fn(), createQueryBuilder: jest.fn() }; + const ticketActivityRepo = { create: jest.fn((row) => row), save: jest.fn() }; + const knowledgeActivityRepo = { create: jest.fn((row) => row), save: jest.fn() }; + const clientsRepository = { findByIdOrThrow: jest.fn() }; + const fileStorage = { writeAgenstraAttachmentFile: jest.fn(), readAgenstraAttachmentFile: jest.fn() }; + const fileSystemProxy = { writeFile: jest.fn() }; + const notificationPublisher = { publishAttachment: jest.fn() }; + + const service = new AgenstraAttachmentsService( + attachmentRepo as never, + ticketRepo as never, + knowledgeRepo as never, + ticketActivityRepo as never, + knowledgeActivityRepo as never, + clientsRepository as never, + fileStorage as never, + fileSystemProxy as never, + notificationPublisher as never, + ); + + beforeEach(() => jest.clearAllMocks()); + + it('stores ticket attachments under generated keys and records activity', async () => { + ticketRepo.findOne.mockResolvedValue({ id: 'ticket-1', clientId: 'client-1' }); + + const result = await service.uploadForTicket( + 'ticket-1', + { buffer: Buffer.from('hello'), originalname: '../notes.txt', mimetype: 'text/plain' }, + 'user-1', + ); + + expect(result.fileName).toBe('notes.txt'); + expect(result.ownerType).toBe(AgenstraAttachmentOwnerType.TICKET); + expect(fileStorage.writeAgenstraAttachmentFile).toHaveBeenCalledWith( + expect.stringContaining('client-1/ticket/ticket-1/'), + Buffer.from('hello'), + ); + expect(ticketActivityRepo.save).toHaveBeenCalledWith(expect.objectContaining({ actionType: 'ATTACHMENT_ADDED' })); + expect(notificationPublisher.publishAttachment).toHaveBeenCalledWith('attachment.created', result); + }); + + it('rejects missing files before storing bytes', async () => { + ticketRepo.findOne.mockResolvedValue({ id: 'ticket-1', clientId: 'client-1' }); + + await expect(service.uploadForTicket('ticket-1', undefined)).rejects.toBeInstanceOf(BadRequestException); + expect(fileStorage.writeAgenstraAttachmentFile).not.toHaveBeenCalled(); + }); + + it('mirrors attachment context into the agent read-only context path', async () => { + const row = { + id: 'att-1', + clientId: 'client-1', + ownerType: AgenstraAttachmentOwnerType.CHAT, + ownerId: 'owner-1', + fileName: 'brief.pdf', + contentType: 'application/pdf', + byteSize: '3', + storageKey: 'k', + contentSha256: 'abc', + createdByUserId: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }; + attachmentRepo.find.mockResolvedValue([row]); + fileStorage.readAgenstraAttachmentFile.mockResolvedValue(Buffer.from('pdf')); + + const result = await service.resolveContextAttachments({ + clientId: 'client-1', + agentId: 'agent-1', + attachmentIds: ['att-1'], + }); + + expect(fileSystemProxy.writeFile).toHaveBeenCalledWith( + 'client-1', + 'agent-1', + expect.stringContaining('/opt/agenstra/context/client-1/chat/owner-1/att-1-brief.pdf'), + Buffer.from('pdf'), + 'app', + { contentType: 'application/pdf' }, + ); + expect(result[0].agentPath).toContain('/opt/agenstra/context/client-1/chat/owner-1/att-1-brief.pdf'); + }); + + it('stores knowledge page attachments only for pages', async () => { + knowledgeRepo.findOne.mockResolvedValue({ id: 'page-1', clientId: 'client-1', nodeType: KnowledgeNodeType.PAGE }); + + const result = await service.uploadForKnowledgePage('page-1', { buffer: Buffer.from('x'), originalname: 'p.md' }); + + expect(result.ownerType).toBe(AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE); + expect(knowledgeActivityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ actionType: 'ATTACHMENT_ADDED' }), + ); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts new file mode 100644 index 000000000..3622d4f0b --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-attachments.service.ts @@ -0,0 +1,319 @@ +import { randomUUID } from 'crypto'; +import * as path from 'path'; + +import { FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { BadRequestException, Injectable, NotFoundException, PayloadTooLargeException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { AttachmentResponseDto } from '../dto/attachment.dto'; +import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity'; +import { AgenstraAttachmentOwnerType } from '../entities/attachment.enums'; +import { KnowledgePageActivityEntity } from '../entities/knowledge-page-activity.entity'; +import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; +import { KnowledgeActionType, KnowledgeActorType, KnowledgeNodeType } from '../entities/knowledge-node.enums'; +import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { TicketEntity } from '../entities/ticket.entity'; +import { TicketActionType, TicketActorType } from '../entities/ticket.enums'; +import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentFileSystemProxyService } from './client-agent-file-system-proxy.service'; + +export type UploadedAttachmentFile = { buffer: Buffer; size?: number; originalname?: string; mimetype?: string }; + +export interface AttachmentContextReference { + id: string; + fileName: string; + contentType: string; + byteSize: number; + agentPath: string; + downloadUrl: string; +} + +const MAX_ATTACHMENT_BYTES = Number.parseInt(process.env.AGENSTRA_ATTACHMENT_MAX_BYTES || `${25 * 1024 * 1024}`, 10); + +@Injectable() +export class AgenstraAttachmentsService { + constructor( + @InjectRepository(AgenstraAttachmentEntity) + private readonly attachmentRepo: Repository, + @InjectRepository(TicketEntity) + private readonly ticketRepo: Repository, + @InjectRepository(KnowledgeNodeEntity) + private readonly knowledgeRepo: Repository, + @InjectRepository(TicketActivityEntity) + private readonly ticketActivityRepo: Repository, + @InjectRepository(KnowledgePageActivityEntity) + private readonly knowledgeActivityRepo: Repository, + private readonly clientsRepository: ClientsRepository, + private readonly fileStorage: FileStorageService, + private readonly fileSystemProxy: ClientAgentFileSystemProxyService, + private readonly notificationPublisher: AgenstraNotificationPublisher, + ) {} + + async uploadForTicket(ticketId: string, file: UploadedAttachmentFile | undefined, userId?: string | null) { + return await this.store( + AgenstraAttachmentOwnerType.TICKET, + ticketId, + await this.getTicketClientId(ticketId), + file, + userId, + ); + } + + async uploadForKnowledgePage(pageId: string, file: UploadedAttachmentFile | undefined, userId?: string | null) { + return await this.store( + AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE, + pageId, + await this.getKnowledgePageClientId(pageId), + file, + userId, + ); + } + + async getTicketClientId(ticketId: string): Promise { + const ticket = await this.ticketRepo.findOne({ where: { id: ticketId }, select: ['id', 'clientId'] }); + if (!ticket) throw new NotFoundException('Ticket not found'); + return ticket.clientId; + } + + async getKnowledgePageClientId(pageId: string): Promise { + const page = await this.knowledgeRepo.findOne({ where: { id: pageId }, select: ['id', 'clientId', 'nodeType'] }); + if (!page || page.nodeType !== KnowledgeNodeType.PAGE) throw new NotFoundException('Knowledge page not found'); + return page.clientId; + } + + async uploadForChat(clientId: string, file: UploadedAttachmentFile | undefined, userId?: string | null) { + await this.clientsRepository.findByIdOrThrow(clientId); + return await this.store(AgenstraAttachmentOwnerType.CHAT, randomUUID(), clientId, file, userId); + } + + async listForOwner(ownerType: AgenstraAttachmentOwnerType, ownerId: string): Promise { + const rows = await this.attachmentRepo.find({ where: { ownerType, ownerId }, order: { createdAt: 'ASC' } }); + return rows.map((row) => this.toDto(row)); + } + + async deleteForOwner( + ownerType: AgenstraAttachmentOwnerType, + ownerId: string, + attachmentId: string, + userId?: string | null, + ): Promise { + const row = await this.getForOwner(ownerType, ownerId, attachmentId); + const dto = this.toDto(row); + await this.attachmentRepo.remove(row); + await this.recordAttachmentActivity(dto, userId ?? null, 'removed'); + this.notificationPublisher.publishAttachment('attachment.deleted', dto); + } + + async getForOwner( + ownerType: AgenstraAttachmentOwnerType, + ownerId: string, + attachmentId: string, + ): Promise { + const row = await this.attachmentRepo.findOne({ where: { id: attachmentId, ownerType, ownerId } }); + if (!row) throw new NotFoundException('Attachment not found'); + return row; + } + + async readForDownload(attachmentId: string): Promise<{ meta: AttachmentResponseDto; buffer: Buffer }> { + const row = await this.getById(attachmentId); + const buffer = await this.fileStorage.readAgenstraAttachmentFile(row.storageKey); + return { meta: this.toDto(row), buffer }; + } + + async getById(attachmentId: string): Promise { + const row = await this.attachmentRepo.findOne({ where: { id: attachmentId } }); + if (!row) throw new NotFoundException('Attachment not found'); + return row; + } + + async assertAttachmentClient(attachmentId: string, clientId: string): Promise { + const row = await this.attachmentRepo.findOne({ where: { id: attachmentId, clientId } }); + if (!row) throw new NotFoundException('Attachment not found'); + return row; + } + + async resolveContextAttachments(params: { + clientId: string; + agentId: string; + attachmentIds?: string[]; + ticketShas?: string[]; + knowledgeShas?: string[]; + }): Promise { + const rows: AgenstraAttachmentEntity[] = []; + const explicitIds = [...new Set((params.attachmentIds ?? []).filter(Boolean))]; + if (explicitIds.length) { + rows.push(...(await this.attachmentRepo.find({ where: { id: In(explicitIds), clientId: params.clientId } }))); + } + + const ticketIds = await this.resolveTicketIds(params.clientId, params.ticketShas ?? []); + if (ticketIds.length) { + rows.push( + ...(await this.attachmentRepo.find({ + where: { clientId: params.clientId, ownerType: AgenstraAttachmentOwnerType.TICKET, ownerId: In(ticketIds) }, + })), + ); + } + + const pageIds = await this.resolveKnowledgePageIds(params.clientId, params.knowledgeShas ?? []); + if (pageIds.length) { + rows.push( + ...(await this.attachmentRepo.find({ + where: { + clientId: params.clientId, + ownerType: AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE, + ownerId: In(pageIds), + }, + })), + ); + } + + const unique = Array.from(new Map(rows.map((row) => [row.id, row])).values()); + const mirrored: AttachmentContextReference[] = []; + for (const row of unique) { + const agentPath = this.agentContextPath(row); + const buffer = await this.fileStorage.readAgenstraAttachmentFile(row.storageKey); + await this.fileSystemProxy.writeFile(params.clientId, params.agentId, agentPath, buffer, 'app', { + contentType: row.contentType, + }); + mirrored.push({ + id: row.id, + fileName: row.fileName, + contentType: row.contentType, + byteSize: Number(row.byteSize), + agentPath, + downloadUrl: `/api/attachments/${row.id}/download`, + }); + } + return mirrored; + } + + private async resolveTicketIds(clientId: string, shas: string[]): Promise { + const ids: string[] = []; + for (const raw of shas) { + const sha = raw.trim().toLowerCase(); + if (!sha) continue; + const qb = this.ticketRepo + .createQueryBuilder('t') + .select(['t.id']) + .where('t.client_id = :clientId', { clientId }); + if (sha.length >= 40) qb.andWhere('t.long_sha = :sha', { sha: sha.slice(0, 40) }); + else qb.andWhere('t.long_sha LIKE :prefix', { prefix: `${sha}%` }); + const row = await qb.orderBy('t.created_at', 'DESC').getOne(); + if (row?.id) ids.push(row.id); + } + return [...new Set(ids)]; + } + + private async resolveKnowledgePageIds(clientId: string, shas: string[]): Promise { + const ids: string[] = []; + for (const raw of shas) { + const sha = raw.trim().toLowerCase(); + if (!sha) continue; + const qb = this.knowledgeRepo + .createQueryBuilder('n') + .select(['n.id']) + .where('n.client_id = :clientId', { clientId }) + .andWhere('n.node_type = :nodeType', { nodeType: KnowledgeNodeType.PAGE }); + if (sha.length >= 40) qb.andWhere('n.long_sha = :sha', { sha: sha.slice(0, 40) }); + else qb.andWhere('n.long_sha LIKE :prefix', { prefix: `${sha}%` }); + const row = await qb.orderBy('n.created_at', 'DESC').getOne(); + if (row?.id) ids.push(row.id); + } + return [...new Set(ids)]; + } + + private async store( + ownerType: AgenstraAttachmentOwnerType, + ownerId: string, + clientId: string, + file: UploadedAttachmentFile | undefined, + userId?: string | null, + ): Promise { + if (!file?.buffer?.length) throw new BadRequestException('Attachment file is required'); + const size = file.size ?? file.buffer.byteLength; + if (size > MAX_ATTACHMENT_BYTES) throw new PayloadTooLargeException('Attachment exceeds maximum size'); + const fileName = this.sanitizeFileName(file.originalname || 'attachment'); + const id = randomUUID(); + const storageKey = `${clientId}/${ownerType}/${ownerId}/${id}/${fileName}`; + await this.fileStorage.writeAgenstraAttachmentFile(storageKey, file.buffer); + const row = await this.attachmentRepo.save( + this.attachmentRepo.create({ + id, + clientId, + ownerType, + ownerId, + fileName, + contentType: file.mimetype || 'application/octet-stream', + byteSize: String(size), + storageKey, + contentSha256: AgenstraAttachmentEntity.contentSha256(file.buffer), + createdByUserId: userId ?? null, + }), + ); + const dto = this.toDto(row); + await this.recordAttachmentActivity(dto, userId ?? null, 'added'); + this.notificationPublisher.publishAttachment('attachment.created', dto); + return dto; + } + + private async recordAttachmentActivity( + attachment: AttachmentResponseDto, + userId: string | null, + op: 'added' | 'removed', + ): Promise { + const payload = { attachmentId: attachment.id, fileName: attachment.fileName, byteSize: attachment.byteSize }; + + if (attachment.ownerType === AgenstraAttachmentOwnerType.TICKET) { + await this.ticketActivityRepo.save( + this.ticketActivityRepo.create({ + ticketId: attachment.ownerId, + actorType: userId ? TicketActorType.HUMAN : TicketActorType.SYSTEM, + actorUserId: userId, + actionType: op === 'added' ? TicketActionType.ATTACHMENT_ADDED : TicketActionType.ATTACHMENT_REMOVED, + payload, + }), + ); + } else if (attachment.ownerType === AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE) { + await this.knowledgeActivityRepo.save( + this.knowledgeActivityRepo.create({ + pageId: attachment.ownerId, + actorType: userId ? KnowledgeActorType.HUMAN : KnowledgeActorType.SYSTEM, + actorUserId: userId, + actionType: op === 'added' ? KnowledgeActionType.ATTACHMENT_ADDED : KnowledgeActionType.ATTACHMENT_REMOVED, + payload, + }), + ); + } + } + + private sanitizeFileName(name: string): string { + const base = path + .basename(name.replace(/\\/g, '/')) + .replace(/[\0\r\n]/g, '') + .trim(); + return (base || 'attachment').slice(0, 255); + } + + private agentContextPath(row: AgenstraAttachmentEntity): string { + return `/opt/agenstra/context/${row.clientId}/${row.ownerType}/${row.ownerId}/${row.id}-${row.fileName}`; + } + + private toDto(row: AgenstraAttachmentEntity): AttachmentResponseDto { + return { + id: row.id, + clientId: row.clientId, + ownerType: row.ownerType, + ownerId: row.ownerId, + fileName: row.fileName, + contentType: row.contentType, + byteSize: Number(row.byteSize), + contentSha256: row.contentSha256, + downloadUrl: `/api/attachments/${row.id}/download`, + agentPath: this.agentContextPath(row), + createdByUserId: row.createdByUserId ?? null, + createdAt: row.createdAt, + }; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/auto-context-resolver.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/auto-context-resolver.service.ts index a7e6104eb..fe5fc4cbf 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/auto-context-resolver.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/auto-context-resolver.service.ts @@ -13,6 +13,15 @@ import { TicketsService } from './tickets.service'; interface ContextInjectionPayload { includeWorkspace?: boolean; environmentIds?: string[]; + attachmentIds?: string[]; + attachmentContexts?: Array<{ + id: string; + fileName: string; + contentType: string; + byteSize: number; + agentPath: string; + downloadUrl: string; + }>; ticketShas?: string[]; ticketContexts?: string[]; knowledgeShas?: string[]; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts index 439d1361b..521667fe8 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts @@ -47,6 +47,7 @@ describe('KnowledgeTreeService', () => { const clientsRepository = {} as never; const clientUsersRepository = {} as never; const usersRepository: any = { findById: jest.fn() }; + const attachmentRepo: any = { find: jest.fn().mockResolvedValue([]) }; const ticketsService: any = { getPrototypePromptByClientSha: jest.fn(), assertTicketInClient: jest.fn().mockResolvedValue({ id: 'ticket-1', clientId: 'c1' }), @@ -72,6 +73,7 @@ describe('KnowledgeTreeService', () => { nodeRepo, relationRepo, pageActivityRepo, + attachmentRepo, clientsRepository, clientUsersRepository, usersRepository, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts index 0eece8bb3..31c6a68cf 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts @@ -32,6 +32,8 @@ import { UploadKnowledgeTextResultDto, } from '../dto/knowledge'; import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; +import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity'; +import { AgenstraAttachmentOwnerType } from '../entities/attachment.enums'; import { KnowledgeActionType, KnowledgeActorType, @@ -70,6 +72,8 @@ export class KnowledgeTreeService { private readonly knowledgeRelationRepo: Repository, @InjectRepository(KnowledgePageActivityEntity) private readonly knowledgePageActivityRepo: Repository, + @InjectRepository(AgenstraAttachmentEntity) + private readonly attachmentRepo: Repository, private readonly clientsRepository: ClientsRepository, private readonly clientUsersRepository: ClientUsersRepository, private readonly usersRepository: UsersRepository, @@ -157,8 +161,15 @@ export class KnowledgeTreeService { return (siblings[0]?.sortOrder ?? -1) + 1; } - private mapNode(row: KnowledgeNodeEntity): KnowledgeNodeResponseDto { + private async mapNode(row: KnowledgeNodeEntity): Promise { const longSha = row.longSha ?? KnowledgeNodeEntity.deriveLongSha(row.id); + const attachments = + row.nodeType === KnowledgeNodeType.PAGE + ? await this.attachmentRepo.find({ + where: { ownerType: AgenstraAttachmentOwnerType.KNOWLEDGE_PAGE, ownerId: row.id }, + order: { createdAt: 'ASC' }, + }) + : []; return { id: row.id, @@ -172,6 +183,20 @@ export class KnowledgeTreeService { title: row.title, content: row.content ?? null, sortOrder: row.sortOrder, + attachments: attachments.map((attachment) => ({ + id: attachment.id, + clientId: attachment.clientId, + ownerType: attachment.ownerType, + ownerId: attachment.ownerId, + fileName: attachment.fileName, + contentType: attachment.contentType, + byteSize: Number(attachment.byteSize), + contentSha256: attachment.contentSha256, + downloadUrl: `/api/attachments/${attachment.id}/download`, + agentPath: `/opt/agenstra/context/${attachment.clientId}/${attachment.ownerType}/${attachment.ownerId}/${attachment.id}-${attachment.fileName}`, + createdByUserId: attachment.createdByUserId ?? null, + createdAt: attachment.createdAt, + })), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -318,7 +343,7 @@ export class KnowledgeTreeService { void this.searchIndex.upsertSafe('knowledge-nodes', mapKnowledgeNodeToSearchDocument(saved)); - return this.mapNode(saved); + return await this.mapNode(saved); } async listNodes(clientId: string, req?: RequestWithUser, search?: string): Promise { @@ -359,7 +384,7 @@ export class KnowledgeTreeService { }); } - return rows.map((row) => this.mapNode(row)); + return await Promise.all(rows.map((row) => this.mapNode(row))); } async getTree(clientId: string, req?: RequestWithUser, search?: string): Promise { @@ -391,7 +416,7 @@ export class KnowledgeTreeService { async getNode(id: string, req?: RequestWithUser): Promise { const node = await this.assertNodeReadable(id, req); - return this.mapNode(node); + return await this.mapNode(node); } async updateNode(id: string, dto: UpdateKnowledgeNodeDto, req?: RequestWithUser): Promise { @@ -464,7 +489,7 @@ export class KnowledgeTreeService { void this.searchIndex.upsertSafe('knowledge-nodes', mapKnowledgeNodeToSearchDocument(saved)); - return this.mapNode(saved); + return await this.mapNode(saved); } async reorderNode( @@ -489,7 +514,7 @@ export class KnowledgeTreeService { ); } - return this.mapNode(saved); + return await this.mapNode(saved); } async deleteNode(id: string, req?: RequestWithUser, releaseExternalSyncMarker = false): Promise { @@ -599,7 +624,7 @@ export class KnowledgeTreeService { await this.appendPageActivity(saved.id, saved.clientId, KnowledgeActionType.CONTENT_UPDATED, {}, reqSystem); } - return this.mapNode(saved); + return await this.mapNode(saved); } const sortOrder = await this.nextSortOrder(clientId, parentFolderId ?? null); @@ -627,7 +652,7 @@ export class KnowledgeTreeService { reqSystem, ); - return this.mapNode(saved); + return await this.mapNode(saved); } /** @@ -674,7 +699,7 @@ export class KnowledgeTreeService { this.emitKnowledgeTreeChanged(saved.clientId); - return this.mapNode(saved); + return await this.mapNode(saved); } const sortOrder = await this.nextSortOrder(clientId, parentFolderId ?? null); @@ -694,7 +719,7 @@ export class KnowledgeTreeService { this.emitKnowledgeTreeChanged(saved.clientId); - return this.mapNode(saved); + return await this.mapNode(saved); } private static readonly ALLOWED_UPLOAD_EXTENSIONS = new Set(['.md', '.mmd', '.txt']); @@ -932,7 +957,7 @@ export class KnowledgeTreeService { ); } - return this.mapNode(duplicated); + return await this.mapNode(duplicated); } async listPageActivity( @@ -980,7 +1005,7 @@ export class KnowledgeTreeService { .getOne(); } - return node ? this.mapNode(node) : null; + return node ? await this.mapNode(node) : null; } async createRelation(dto: CreateKnowledgeRelationDto, req?: RequestWithUser): Promise { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts index 764d99933..9a9cfc3f5 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts @@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity'; import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; @@ -104,6 +105,7 @@ describe('TicketsService', () => { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), }; + const attachmentRepo = { find: jest.fn().mockResolvedValue([]) }; const usersRepository = { findById: jest.fn().mockResolvedValue(null), }; @@ -127,6 +129,7 @@ describe('TicketsService', () => { } as unknown as TicketEntity; ticketRepo.findOne.mockResolvedValue(ticket); ticketRepo.find.mockResolvedValue([ticket]); + attachmentRepo.find.mockResolvedValue([]); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -136,6 +139,7 @@ describe('TicketsService', () => { { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, { provide: getRepositoryToken(TicketBodyGenerationSessionEntity), useValue: bodySessionRepo }, { provide: getRepositoryToken(TicketAutomationEntity), useValue: ticketAutomationRepo }, + { provide: getRepositoryToken(AgenstraAttachmentEntity), useValue: attachmentRepo }, { provide: ClientsRepository, useValue: {} }, { provide: ClientUsersRepository, useValue: {} }, { provide: UsersRepository, useValue: usersRepository }, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts index da6fbde19..901156760 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts @@ -36,6 +36,8 @@ import { UpdateTicketDto, } from '../dto/tickets'; import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity'; +import { AgenstraAttachmentOwnerType } from '../entities/attachment.enums'; import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; @@ -101,6 +103,8 @@ export class TicketsService { private readonly bodySessionRepo: Repository, @InjectRepository(TicketAutomationEntity) private readonly ticketAutomationRepo: Repository, + @InjectRepository(AgenstraAttachmentEntity) + private readonly attachmentRepo: Repository, private readonly clientsRepository: ClientsRepository, private readonly clientUsersRepository: ClientUsersRepository, private readonly usersRepository: UsersRepository, @@ -1448,6 +1452,10 @@ export class TicketsService { const own = countMarkdownCheckboxTasks(row.content); const child = descendantTotals.get(row.id) ?? { open: 0, done: 0 }; const longSha = row.longSha ?? this.deriveTicketLongSha(row.id); + const attachments = await this.attachmentRepo.find({ + where: { ownerType: AgenstraAttachmentOwnerType.TICKET, ownerId: row.id }, + order: { createdAt: 'ASC' }, + }); return { id: row.id, @@ -1473,6 +1481,20 @@ export class TicketsService { done: own.done, children: { open: child.open, done: child.done }, }, + attachments: attachments.map((attachment) => ({ + id: attachment.id, + clientId: attachment.clientId, + ownerType: attachment.ownerType, + ownerId: attachment.ownerId, + fileName: attachment.fileName, + contentType: attachment.contentType, + byteSize: Number(attachment.byteSize), + contentSha256: attachment.contentSha256, + downloadUrl: `/api/attachments/${attachment.id}/download`, + agentPath: `/opt/agenstra/context/${attachment.clientId}/${attachment.ownerType}/${attachment.ownerId}/${attachment.id}-${attachment.fileName}`, + createdByUserId: attachment.createdByUserId ?? null, + createdAt: attachment.createdAt, + })), }; } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml index 6cd0ad68e..73e0988cb 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml @@ -453,6 +453,28 @@ components: name: Authorization in: header description: 'Bearer JWT from Keycloak, e.g. Authorization: Bearer ' + schemas: + AttachmentContext: + type: object + required: [id, fileName, contentType, byteSize, downloadUrl] + properties: + id: + type: string + format: uuid + fileName: + type: string + contentType: + type: string + byteSize: + type: integer + contentSha256: + type: string + downloadUrl: + type: string + agentPath: + type: string + nullable: true + description: Read-only path inside the agent container after controller-side context mirroring. messages: Login: name: Login @@ -597,6 +619,17 @@ components: description: Optional knowledge page/folder SHA references for prompt enrichment. items: type: string + attachmentIds: + type: array + description: Optional attachment ids uploaded by the controller for read-only mirroring into `/opt/agenstra/context/...`. + items: + type: string + format: uuid + attachmentContexts: + type: array + description: Resolved attachment metadata including agent container paths; normally added by the controller. + items: + $ref: '#/components/schemas/AttachmentContext' autoEnrichmentEnabled: type: boolean description: Enable prompt-based automatic enrichment (default true when omitted). @@ -752,6 +785,15 @@ components: type: array items: type: string + attachmentIds: + type: array + items: + type: string + format: uuid + attachmentContexts: + type: array + items: + $ref: '#/components/schemas/AttachmentContext' autoEnrichmentEnabled: type: boolean ChatEnhanceResult: @@ -839,6 +881,15 @@ components: type: array items: type: string + attachmentIds: + type: array + items: + type: string + format: uuid + attachmentContexts: + type: array + items: + $ref: '#/components/schemas/AttachmentContext' autoEnrichmentEnabled: type: boolean TicketBodyResult: @@ -918,6 +969,11 @@ components: chatId: type: string description: User-visible chat session id when the message belongs to a persisted chat + attachments: + type: array + items: + $ref: '#/components/schemas/AttachmentContext' + description: Files attached to this user message, including read-only agentPath when mirrored. - type: object required: [from, response, timestamp] properties: diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts index 5d9642872..15b095d89 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts @@ -11,6 +11,15 @@ import { import { AgentChatSessionEntity } from './agent-chat-session.entity'; import { AgentEntity } from './agent.entity'; +export interface AgentMessageAttachmentJson { + id: string; + fileName: string; + contentType: string; + byteSize: number; + downloadUrl: string; + agentPath?: string | null; +} + /** * Agent message entity representing a chat message bound to an agent chat session. * Each message has an actor (user or agent) and the message content. @@ -43,6 +52,9 @@ export class AgentMessageEntity { @Column({ type: 'boolean', name: 'filtered', default: false }) filtered!: boolean; + @Column({ type: 'jsonb', name: 'attachments', nullable: true }) + attachments?: AgentMessageAttachmentJson[] | null; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 842c42a2c..937ccc61d 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -22,6 +22,7 @@ import { import { AgentEventEnvelope, AgentInteractionQueryPayload, AgentResponseMode } from '../providers/agent-events.types'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentResponseObject } from '../providers/agent-provider.interface'; +import type { AgentMessageAttachmentJson } from '../entities/agent-message.entity'; import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { AppliedFilterInfo, @@ -77,6 +78,7 @@ interface ChatPayload { /** User-visible chat session id; defaults to primary when omitted (ignored for reserved ACP suffixes). */ chatId?: string; contextInjection?: ContextInjectionPayload; + attachments?: AgentMessageAttachmentJson[]; } interface RestoreChatPayload { @@ -192,6 +194,7 @@ interface UserChatMessageData { chatId?: string; /** Persisted `agent_messages.id` when available. */ id?: string; + attachments?: AgentMessageAttachmentJson[]; } interface AgentChatMessageData { @@ -627,18 +630,27 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, chatSessionId?: string; chatIdFields: { chatId?: string }; filtered?: boolean; + attachments?: AgentMessageAttachmentJson[]; }, ): Promise { let messageId: string | undefined; if (!ephemeral) { try { - const persisted = await this.agentMessagesService.createUserMessage( - agentUuid, - text, - opts.filtered === true, - opts.chatSessionId, - ); + const persisted = opts.attachments?.length + ? await this.agentMessagesService.createUserMessage( + agentUuid, + text, + opts.filtered === true, + opts.chatSessionId, + opts.attachments, + ) + : await this.agentMessagesService.createUserMessage( + agentUuid, + text, + opts.filtered === true, + opts.chatSessionId, + ); messageId = persisted.id; } catch (persistError) { @@ -659,6 +671,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, text, timestamp, ...opts.chatIdFields, + ...(opts.attachments?.length ? { attachments: opts.attachments } : {}), }), ); } @@ -822,6 +835,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ticketContextCount: contextInjection.ticketContexts?.length ?? 0, knowledgeShas: contextInjection.knowledgeShas ?? [], knowledgeContextCount: contextInjection.knowledgeContexts?.length ?? 0, + attachmentCount: contextInjection.attachmentContexts?.length ?? 0, }; return [ @@ -847,12 +861,38 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ticketContextCount: contextInjection.ticketContexts?.length ?? 0, knowledgeShas: contextInjection.knowledgeShas ?? [], knowledgeContextCount: contextInjection.knowledgeContexts?.length ?? 0, + attachmentCount: contextInjection.attachmentContexts?.length ?? 0, }, isError: false, }, ]; } + private normalizeChatAttachments(attachments?: AgentMessageAttachmentJson[]): AgentMessageAttachmentJson[] { + if (!Array.isArray(attachments)) { + return []; + } + + const normalized = new Map(); + + for (const attachment of attachments) { + if (!attachment || typeof attachment.id !== 'string' || !attachment.id.trim()) { + continue; + } + + normalized.set(attachment.id, { + id: attachment.id, + fileName: String(attachment.fileName ?? 'attachment'), + contentType: String(attachment.contentType ?? 'application/octet-stream'), + byteSize: Number(attachment.byteSize ?? 0), + downloadUrl: String(attachment.downloadUrl ?? ''), + agentPath: attachment.agentPath ?? null, + }); + } + + return [...normalized.values()]; + } + private mergeTranscriptPartsIntoFinalResponse( finalResponse: AgentResponseObject | null, prependParts: AgentResponseObject[], @@ -1028,6 +1068,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, const knowledgeContexts = Array.from( new Set((contextInjection.knowledgeContexts ?? []).map((ctx) => ctx.trim()).filter((ctx) => ctx.length > 0)), ); + const attachmentIds = Array.from( + new Set((contextInjection.attachmentIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), + ); + const attachmentContexts = this.normalizeChatAttachments(contextInjection.attachmentContexts); const allowedIds: string[] = []; const environmentContainerTypes: NonNullable = []; const needsAgentLookup = includeWorkspace || requestedIds.length > 0; @@ -1068,7 +1112,9 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ticketShas.length === 0 && ticketContexts.length === 0 && knowledgeShas.length === 0 && - knowledgeContexts.length === 0 + knowledgeContexts.length === 0 && + attachmentIds.length === 0 && + attachmentContexts.length === 0 ) { return undefined; } @@ -1086,7 +1132,9 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ticketContexts, knowledgeShas, knowledgeContexts, - workspaceContainerType, + ...(attachmentIds.length ? { attachmentIds } : {}), + ...(attachmentContexts.length ? { attachmentContexts } : {}), + ...(workspaceContainerType ? { workspaceContainerType } : {}), environmentContainerTypes, }; } @@ -1418,6 +1466,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, text: messageEntity.message, timestamp, chatId: session.id, + ...(messageEntity.attachments?.length ? { attachments: messageEntity.attachments } : {}), }); } else if (messageEntity.actor === 'agent') { let response: AgentResponseObject | string; @@ -1709,6 +1758,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, const pendingHydrationSummary = this.agentSessionHydrationService.consumePendingSummary(agentUuid); const messageWithHydration = this.prependHiddenHydrationContext(filteredMessage, pendingHydrationSummary); const contextInjection = await this.normalizeContextInjection(agentUuid, data.contextInjection); + const chatAttachments = this.normalizeChatAttachments(data.attachments ?? contextInjection?.attachmentContexts); const messageToUse = this.promptContextComposer.composeChatMessage(messageWithHydration, contextInjection); const enrichmentTranscriptParts = this.buildEnrichmentTranscriptParts(contextInjection, correlationId); @@ -1742,6 +1792,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, chatSessionId, chatIdFields, filtered: incomingFilterResult.status === 'filtered', + attachments: chatAttachments, }); this.emitOrPersistChatEvent( diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts index 942b56823..36b6d9f3a 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { AgentMessageEntity } from '../entities/agent-message.entity'; +import { AgentMessageEntity, type AgentMessageAttachmentJson } from '../entities/agent-message.entity'; import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; import { AgentChatSessionsService } from './agent-chat-sessions.service'; @@ -30,6 +30,7 @@ export class AgentMessagesService { message: string, filtered = false, chatSessionId?: string, + attachments?: AgentMessageAttachmentJson[], ): Promise { const session = await this.agentChatSessionsService.resolveSessionForChat(agentId, chatSessionId); const messageEntity = await this.agentMessagesRepository.create({ @@ -38,6 +39,7 @@ export class AgentMessagesService { actor: 'user', message: message.trim(), filtered, + ...(attachments?.length ? { attachments } : {}), }); await this.agentChatSessionsService.touchLastMessageAt(session.id, messageEntity.createdAt); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/prompt-context-composer.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/prompt-context-composer.service.ts index d2732bed9..6bb6cc903 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/prompt-context-composer.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/prompt-context-composer.service.ts @@ -23,6 +23,9 @@ export class PromptContextComposerService { const ticketContexts = (contextInjection.ticketContexts ?? []).filter((ctx) => ctx.trim().length > 0); const knowledgeShas = (contextInjection.knowledgeShas ?? []).filter((sha) => sha.trim().length > 0); const knowledgeContexts = (contextInjection.knowledgeContexts ?? []).filter((ctx) => ctx.trim().length > 0); + const attachmentContexts = (contextInjection.attachmentContexts ?? []).filter( + (attachment) => attachment.agentPath?.trim().length > 0, + ); if (environmentIds.length > 0) { const environmentLine = formatEnvironmentContextLine( @@ -61,6 +64,16 @@ export class PromptContextComposerService { } } + if (attachmentContexts.length > 0) { + lines.push('- Read-only attachment files are mounted in the container at:'); + + for (const attachment of attachmentContexts) { + lines.push( + ` - ${attachment.fileName} (${attachment.contentType}, ${attachment.byteSize} bytes): ${attachment.agentPath}`, + ); + } + } + if (lines.length === 0) { return ''; } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/types/context-injection.types.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/types/context-injection.types.ts index b692f72e1..ca1ae3b88 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/types/context-injection.types.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/types/context-injection.types.ts @@ -8,6 +8,15 @@ export interface EnvironmentContextReference { export interface ContextInjectionPayload { includeWorkspace?: boolean; environmentIds?: string[]; + attachmentIds?: string[]; + attachmentContexts?: Array<{ + id: string; + fileName: string; + contentType: string; + byteSize: number; + agentPath?: string | null; + downloadUrl: string; + }>; ticketShas?: string[]; ticketContexts?: string[]; knowledgeShas?: string[]; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.spec.ts index 1ee9bd7c2..160fa8fbe 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.spec.ts @@ -183,4 +183,65 @@ describe('KnowledgeService', () => { expect(req.request.params.get('offset')).toBe('30'); req.flush([]); }); + + describe('attachments', () => { + const attachment = { + id: 'att-1', + clientId: 'c1', + ownerType: 'knowledge_page' as const, + ownerId: 'p1', + fileName: 'notes.md', + contentType: 'text/markdown', + byteSize: 7, + contentSha256: 'abc', + downloadUrl: '/api/attachments/att-1/download', + createdByUserId: null, + createdAt: '2026-01-01T00:00:00Z', + }; + + it('listAttachments should GET page attachments', (done) => { + service.listAttachments('p1').subscribe((rows) => { + expect(rows).toEqual([attachment]); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/knowledge/p1/attachments`); + + expect(req.request.method).toBe('GET'); + req.flush([attachment]); + }); + + it('uploadAttachment should POST multipart form data', (done) => { + const file = new File(['# notes'], 'notes.md', { type: 'text/markdown' }); + + service.uploadAttachment('p1', file).subscribe((row) => { + expect(row).toEqual(attachment); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/knowledge/p1/attachments`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body instanceof FormData).toBe(true); + req.flush(attachment); + }); + + it('downloadAttachment should GET shared download endpoint as blob', (done) => { + service.downloadAttachment(attachment).subscribe((blob) => { + expect(blob.size).toBe(7); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/attachments/att-1/download`); + + expect(req.request.method).toBe('GET'); + expect(req.request.responseType).toBe('blob'); + req.flush(new Blob(['# notes'])); + }); + + it('deleteAttachment should DELETE page attachment metadata', (done) => { + service.deleteAttachment('p1', 'att-1').subscribe(() => done()); + const req = httpMock.expectOne(`${apiUrl}/knowledge/p1/attachments/att-1`); + + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); }); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts index ded5570da..ff7ab15df 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts @@ -14,6 +14,7 @@ import type { UpdateKnowledgeNodeDto, UploadKnowledgeTextDto, UploadKnowledgeTextResultDto, + AttachmentResponseDto, } from '../state/knowledge/knowledge.types'; @Injectable({ @@ -94,4 +95,22 @@ export class KnowledgeService { params, }); } + + listAttachments(pageId: string): Observable { + return this.http.get(`${this.apiUrl}/knowledge/${pageId}/attachments`); + } + + uploadAttachment(pageId: string, file: File): Observable { + const form = new FormData(); + form.append('file', file, file.name); + return this.http.post(`${this.apiUrl}/knowledge/${pageId}/attachments`, form); + } + + deleteAttachment(pageId: string, attachmentId: string): Observable { + return this.http.delete(`${this.apiUrl}/knowledge/${pageId}/attachments/${attachmentId}`); + } + + downloadAttachment(attachment: AttachmentResponseDto): Observable { + return this.http.get(`${this.apiUrl}/attachments/${attachment.id}/download`, { responseType: 'blob' }); + } } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts index 4937fb1f8..45f599c82 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts @@ -136,6 +136,67 @@ describe('TicketsService', () => { }); }); + describe('attachments', () => { + const attachment = { + id: 'att-1', + clientId: 'client-1', + ownerType: 'ticket' as const, + ownerId: 'ticket-1', + fileName: 'brief.txt', + contentType: 'text/plain', + byteSize: 5, + contentSha256: 'abc', + downloadUrl: '/api/attachments/att-1/download', + createdByUserId: null, + createdAt: '2026-01-01T00:00:00Z', + }; + + it('listAttachments should GET ticket attachments', (done) => { + service.listAttachments('ticket-1').subscribe((rows) => { + expect(rows).toEqual([attachment]); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/attachments`); + + expect(req.request.method).toBe('GET'); + req.flush([attachment]); + }); + + it('uploadAttachment should POST multipart form data', (done) => { + const file = new File(['hello'], 'brief.txt', { type: 'text/plain' }); + + service.uploadAttachment('ticket-1', file).subscribe((row) => { + expect(row).toEqual(attachment); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/attachments`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body instanceof FormData).toBe(true); + req.flush(attachment); + }); + + it('downloadAttachment should GET shared download endpoint as blob', (done) => { + service.downloadAttachment(attachment).subscribe((blob) => { + expect(blob.size).toBe(5); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/attachments/att-1/download`); + + expect(req.request.method).toBe('GET'); + expect(req.request.responseType).toBe('blob'); + req.flush(new Blob(['hello'])); + }); + + it('deleteAttachment should DELETE ticket attachment metadata', (done) => { + service.deleteAttachment('ticket-1', 'att-1').subscribe(() => done()); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/attachments/att-1`); + + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); + describe('ticket automation', () => { const mockAutomation = { ticketId: 'ticket-1', diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts index a3aa96aec..50e91df83 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts @@ -18,6 +18,7 @@ import type { PrototypePromptResponseDto, StartBodyGenerationSessionResponseDto, TicketActivityResponseDto, + AttachmentResponseDto, TicketCommentResponseDto, TicketResponseDto, UpdateTicketDto, @@ -110,6 +111,31 @@ export class TicketsService { }); } + listAttachments(ticketId: string): Observable { + return this.http.get(`${this.apiUrl}/tickets/${ticketId}/attachments`); + } + + uploadAttachment(ticketId: string, file: File): Observable { + const form = new FormData(); + form.append('file', file, file.name); + return this.http.post(`${this.apiUrl}/tickets/${ticketId}/attachments`, form); + } + + deleteAttachment(ticketId: string, attachmentId: string): Observable { + return this.http.delete(`${this.apiUrl}/tickets/${ticketId}/attachments/${attachmentId}`); + } + + downloadAttachment(attachment: AttachmentResponseDto): Observable { + return this.http.get(`${this.apiUrl}/attachments/${attachment.id}/download`, { responseType: 'blob' }); + } + + uploadChatAttachment(clientId: string, file: File): Observable { + const form = new FormData(); + form.append('file', file, file.name); + const params = new HttpParams().set('clientId', clientId); + return this.http.post(`${this.apiUrl}/attachments/chat`, form, { params }); + } + startBodyGenerationSession(ticketId: string, agentId?: string): Observable { return this.http.post( `${this.apiUrl}/tickets/${ticketId}/body-generation-sessions`, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts index d62cc7ee1..84c543bfc 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts @@ -66,6 +66,7 @@ export interface ChatPayload { correlationId?: string; responseMode?: AgentResponseMode; contextInjection?: ContextInjectionPayload; + attachments?: ChatAttachmentDto[]; /** User-visible chat session id; defaults to primary when omitted. */ chatId?: string; } @@ -153,11 +154,22 @@ export interface GenerateTicketBodyPayload { export interface ContextInjectionPayload { includeWorkspace?: boolean; environmentIds?: string[]; + attachmentIds?: string[]; + attachmentContexts?: ChatAttachmentDto[]; ticketShas?: string[]; knowledgeShas?: string[]; autoEnrichmentEnabled?: boolean; } +export interface ChatAttachmentDto { + id: string; + fileName: string; + contentType: string; + byteSize: number; + downloadUrl: string; + agentPath?: string | null; +} + /** * Result of prompt enhancement (unicast from agents gateway). */ @@ -307,6 +319,7 @@ export interface UserChatMessageData { chatId?: string; /** Persisted agent_messages.id when available. */ id?: string; + attachments?: ChatAttachmentDto[]; } /** diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.actions.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.actions.ts index 2aaddf1ca..f6ecb7c8b 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.actions.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.actions.ts @@ -10,6 +10,7 @@ import type { UpdateKnowledgeNodeDto, UploadKnowledgeTextDto, UploadKnowledgeTextResultDto, + AttachmentResponseDto, } from './knowledge.types'; export const loadKnowledgeTree = createAction('[Knowledge] Load Tree', props<{ clientId: string; search?: string }>()); @@ -119,6 +120,14 @@ export const loadKnowledgeActivityFailure = createAction( '[Knowledge] Load Activity Failure', props<{ error: string }>(), ); +export const knowledgeAttachmentAdded = createAction( + '[Knowledge] Attachment Added', + props<{ pageId: string; attachment: AttachmentResponseDto }>(), +); +export const knowledgeAttachmentRemoved = createAction( + '[Knowledge] Attachment Removed', + props<{ pageId: string; attachmentId: string }>(), +); export const prependKnowledgeActivity = createAction( '[Knowledge] Prepend Activity', props<{ activity: KnowledgePageActivityDto }>(), diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.facade.ts index 26e1a30f4..5bf6f540e 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.facade.ts @@ -18,6 +18,7 @@ import type { CreateKnowledgeNodeDto, CreateKnowledgeRelationDto, KnowledgeRelationSourceType, + AttachmentResponseDto, UpdateKnowledgeNodeDto, UploadKnowledgeTextDto, } from './knowledge.types'; @@ -80,4 +81,12 @@ export class KnowledgeFacade { loadActivity(pageId: string): void { this.store.dispatch(KnowledgeActions.loadKnowledgeActivity({ pageId })); } + + addAttachment(pageId: string, attachment: AttachmentResponseDto): void { + this.store.dispatch(KnowledgeActions.knowledgeAttachmentAdded({ pageId, attachment })); + } + + removeAttachment(pageId: string, attachmentId: string): void { + this.store.dispatch(KnowledgeActions.knowledgeAttachmentRemoved({ pageId, attachmentId })); + } } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.reducer.ts index 1268b179a..8833a88bc 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.reducer.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.reducer.ts @@ -50,6 +50,22 @@ function withoutMutatingId(state: KnowledgeState, id: string): ReadonlySet) => NonNullable, +): KnowledgeNodeDto { + const nextChildren = node.children?.map((child) => updateNodeAttachments(child, pageId, update)); + + if (node.id !== pageId) return nextChildren === node.children ? node : { ...node, children: nextChildren }; + + return { + ...node, + attachments: update(node.attachments ?? []), + children: nextChildren ?? node.children, + }; +} + export const knowledgeReducer = createReducer( initialKnowledgeState, on(KnowledgeActions.loadKnowledgeTree, (state, { clientId }) => ({ @@ -97,6 +113,20 @@ export const knowledgeReducer = createReducer( activityLoading: false, error, })), + on(KnowledgeActions.knowledgeAttachmentAdded, (state, { pageId, attachment }) => { + const append = (attachments: NonNullable) => { + const withoutDup = attachments.filter((a) => a.id !== attachment.id); + return [...withoutDup, attachment]; + }; + + return { ...state, tree: state.tree.map((node) => updateNodeAttachments(node, pageId, append)) }; + }), + on(KnowledgeActions.knowledgeAttachmentRemoved, (state, { pageId, attachmentId }) => { + const remove = (attachments: NonNullable) => + attachments.filter((a) => a.id !== attachmentId); + + return { ...state, tree: state.tree.map((node) => updateNodeAttachments(node, pageId, remove)) }; + }), on(KnowledgeActions.loadKnowledgeTreeFailure, (state, { error }) => ({ ...state, loading: false, error })), on(KnowledgeActions.selectKnowledgeNode, (state, { nodeId }) => ({ ...state, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.types.ts index 18c169173..bf02a32fe 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge/knowledge.types.ts @@ -1,3 +1,6 @@ +import type { AttachmentResponseDto } from '../tickets/tickets.types'; +export type { AttachmentResponseDto } from '../tickets/tickets.types'; + export type KnowledgeNodeType = 'folder' | 'page'; export type KnowledgeRelationSourceType = 'ticket' | 'page'; export type KnowledgeRelationTargetType = 'ticket' | 'folder' | 'page'; @@ -11,7 +14,9 @@ export type KnowledgeActionType = | 'DELETED' | 'DUPLICATED' | 'RELATION_ADDED' - | 'RELATION_REMOVED'; + | 'RELATION_REMOVED' + | 'ATTACHMENT_ADDED' + | 'ATTACHMENT_REMOVED'; export interface KnowledgeNodeShasDto { short: string; @@ -27,6 +32,7 @@ export interface KnowledgeNodeDto { title: string; content?: string | null; sortOrder: number; + attachments?: AttachmentResponseDto[]; createdAt: string; updatedAt: string; children?: KnowledgeNodeDto[]; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts index dfffa784d..809438971 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts @@ -6,6 +6,7 @@ import type { TicketActivityResponseDto, TicketCommentResponseDto, TicketResponseDto, + AttachmentResponseDto, UpdateTicketDto, } from './tickets.types'; @@ -75,6 +76,16 @@ export const addTicketCommentSuccess = createAction( export const addTicketCommentFailure = createAction('[Tickets] Add Comment Failure', props<{ error: string }>()); +export const ticketAttachmentAdded = createAction( + '[Tickets] Attachment Added', + props<{ ticketId: string; attachment: AttachmentResponseDto }>(), +); + +export const ticketAttachmentRemoved = createAction( + '[Tickets] Attachment Removed', + props<{ ticketId: string; attachmentId: string }>(), +); + export const clearTicketsError = createAction('[Tickets] Clear Error'); /** Prepends one activity row (e.g. body-generation session start) while detail is open; list order is newest-first. */ diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts index e904260d2..e27efbd2f 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.ts @@ -12,6 +12,8 @@ import { migrateTicket as migrateTicketAction, openTicketDetail, prependTicketDetailActivity, + ticketAttachmentAdded, + ticketAttachmentRemoved, updateTicket, } from './tickets.actions'; import { @@ -29,6 +31,7 @@ import { } from './tickets.selectors'; import type { CreateTicketDto, + AttachmentResponseDto, ListTicketsParams, TicketActivityResponseDto, TicketCommentResponseDto, @@ -86,6 +89,14 @@ export class TicketsFacade { this.store.dispatch(addTicketComment({ ticketId, body })); } + addAttachment(ticketId: string, attachment: AttachmentResponseDto): void { + this.store.dispatch(ticketAttachmentAdded({ ticketId, attachment })); + } + + removeAttachment(ticketId: string, attachmentId: string): void { + this.store.dispatch(ticketAttachmentRemoved({ ticketId, attachmentId })); + } + clearError(): void { this.store.dispatch(clearTicketsError()); } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts index 112c66d08..61590b7df 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts @@ -32,6 +32,8 @@ import { openTicketDetail, prependTicketDetailActivity, replaceTicketDetailActivity, + ticketAttachmentAdded, + ticketAttachmentRemoved, ticketBoardActivityCreated, ticketBoardCommentCreated, ticketBoardTicketRemoved, @@ -167,6 +169,24 @@ function findTicketInTree(root: TicketResponseDto, id: string): TicketResponseDt return null; } +function updateTicketAttachments( + ticket: TicketResponseDto, + ticketId: string, + update: (attachments: NonNullable) => NonNullable, +): TicketResponseDto { + const nextChildren = ticket.children?.map((child) => updateTicketAttachments(child, ticketId, update)); + + if (ticket.id !== ticketId) { + return nextChildren === ticket.children ? ticket : { ...ticket, children: nextChildren }; + } + + return { + ...ticket, + attachments: update(ticket.attachments ?? []), + children: nextChildren ?? ticket.children, + }; +} + export const ticketsReducer = createReducer( initialTicketsState, on(loadTickets, (state) => ({ ...state, loadingList: true, error: null })), @@ -306,6 +326,26 @@ export const ticketsReducer = createReducer( }; }), on(addTicketCommentFailure, (state, { error }) => ({ ...state, saving: false, error })), + on(ticketAttachmentAdded, (state, { ticketId, attachment }) => { + const append = (attachments: NonNullable) => { + const withoutDup = attachments.filter((a) => a.id !== attachment.id); + return [...withoutDup, attachment]; + }; + const list = state.list.map((ticket) => updateTicketAttachments(ticket, ticketId, append)); + const detail = state.detail ? updateTicketAttachments(state.detail, ticketId, append) : state.detail; + const enriched = enrichTicketsWithSubtaskCounts(list, detail); + + return { ...state, list: enriched.list, detail: enriched.detail }; + }), + on(ticketAttachmentRemoved, (state, { ticketId, attachmentId }) => { + const remove = (attachments: NonNullable) => + attachments.filter((a) => a.id !== attachmentId); + const list = state.list.map((ticket) => updateTicketAttachments(ticket, ticketId, remove)); + const detail = state.detail ? updateTicketAttachments(state.detail, ticketId, remove) : state.detail; + const enriched = enrichTicketsWithSubtaskCounts(list, detail); + + return { ...state, list: enriched.list, detail: enriched.detail }; + }), on(clearTicketsError, (state) => ({ ...state, error: null })), on(prependTicketDetailActivity, (state, { activity }) => { if (state.selectedTicketId !== activity.ticketId) { diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts index bc76cdf16..a2c55c1c0 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts @@ -51,11 +51,27 @@ export interface TicketResponseDto { createdAt: string; updatedAt: string; tasks: TicketTasksDto; + attachments?: AttachmentResponseDto[]; children?: TicketResponseDto[]; /** Present after tickets are processed in the reducer (not from the API). */ subtaskCounts?: TicketSubtaskCountsDto; } +export interface AttachmentResponseDto { + id: string; + clientId: string; + ownerType: string; + ownerId: string; + fileName: string; + contentType: string; + byteSize: number; + contentSha256: string; + downloadUrl: string; + agentPath?: string | null; + createdByUserId?: string | null; + createdAt: string; +} + export interface CreateTicketDto { clientId?: string; parentId?: string | null; diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html index fdac15445..262d223a2 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -965,6 +965,25 @@
@if (formatMessageWithCommandBadge(getText(messageData)); as formattedText) {

} + @if (getMessageAttachments(messageData); as attachments) { + @if (attachments.length) { +
+ @for (attachment of attachments; track attachment.id) { + + {{ attachment.fileName }} + {{ attachment.byteSize | number }} B + + } +
+ } + }
Filtered @@ -978,6 +997,25 @@
@if (formatMessageWithCommandBadge(getText(messageData)); as formattedText) {

} + @if (getMessageAttachments(messageData); as attachments) { + @if (attachments.length) { +
+ @for (attachment of attachments; track attachment.id) { + + {{ attachment.fileName }} + {{ attachment.byteSize | number }} B + + } +
+ } + } {{ formatTimestamp(item.msg.timestamp) }}
@@ -1293,7 +1331,14 @@
> } -
+
@if (isSlashCommandMenuOpenFor('chat')) {
color="secondary" class="border context-toolbar-chip text-truncate" [attr.title]="chip.title" - >{{ chip.label }}{{ chip.label }} } @@ -1538,46 +1583,51 @@
[checked]="autoEnrichmentEnabled()" (checkedChange)="onAutoEnrichmentToggle($event)" /> -

Environment context

-

- Select additional environments to enrich context more specifically. The active environment is included - automatically. -

-
- @if (agents$ | async; as agents) { - @if (activeClient$ | async; as activeClient) { - @for (agent of agents; track agent.id) { - @if (agent.id !== selectedAgentId()) { - - - + +

+ Select additional environments to enrich context more specifically. The active environment is included + automatically. +

+
+ @if (agents$ | async; as agents) { + @if (activeClient$ | async; as activeClient) { + @for (agent of agents; track agent.id) { + @if (agent.id !== selectedAgentId()) { + + + + } } - } - } @else { - @for (agent of agents; track agent.id) { - @if (agent.id !== selectedAgentId()) { - + } @else { + @for (agent of agents; track agent.id) { + @if (agent.id !== selectedAgentId()) { + + } } } } - } -
+
+
}
+
+ + + + @if (selectedAttachmentContexts().length > 0) { +
+ @for (attachment of selectedAttachmentContexts(); track attachment.id) { + + {{ attachment.fileName }} + + + } +
+ } +
Done @@ -1731,7 +1816,14 @@
}
@if (plan.status === 'ready' || plan.status === 'refining') { -
+
@if (planRefineEnhanceError(); as enhErr) { +
+
+

Attachments

+ + + + +
+ @if ((selectedNode()?.attachments ?? []).length === 0) { +

+ No attachments yet. +

+ } @else { +
    + @for (attachment of selectedNode()?.attachments ?? []; track attachment.id) { +
  • + + {{ attachment.fileName }} + ยท {{ attachment.byteSize | number }} B + + + + + + + + + +
  • + } +
+ } +
+

Relations

diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/knowledge/knowledge-board.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/knowledge/knowledge-board.component.ts index c83f170b5..359d89554 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/knowledge/knowledge-board.component.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/knowledge/knowledge-board.component.ts @@ -22,6 +22,7 @@ import { KnowledgeService, TicketsFacade, type ClientResponseDto, + type AttachmentResponseDto, type KnowledgeNodeDto, type KnowledgeNodeType, type KnowledgePageActivityDto, @@ -623,6 +624,51 @@ export class KnowledgeBoardComponent implements AfterViewInit, OnDestroy { this.knowledgeFacade.updateNode(selected.id, { content: this.editContent() }); } + onPageAttachmentSelected(event: Event, node: KnowledgeNodeDto | null): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + + if (!node || node.nodeType !== 'page' || files.length === 0) { + return; + } + + for (const file of files) { + this.knowledgeService + .uploadAttachment(node.id, file) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: () => this.knowledgeFacade.loadTree(node.clientId) }); + } + + input.value = ''; + } + + downloadPageAttachment(attachment: AttachmentResponseDto): void { + this.knowledgeService + .downloadAttachment(attachment) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((blob) => this.saveBlob(blob, attachment.fileName)); + } + + deletePageAttachment(node: KnowledgeNodeDto | null, attachment: AttachmentResponseDto): void { + if (!node || node.nodeType !== 'page') { + return; + } + + this.knowledgeService + .deleteAttachment(node.id, attachment.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.knowledgeFacade.removeAttachment(node.id, attachment.id)); + } + + private saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } + openRelationsModal(): void { this.relationsSearchQuery.set(''); this.relationSuggestionsOpen.set(false); diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html index 054228bd3..9f551adbe 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html @@ -503,6 +503,73 @@

Description< >

+
+
+

Attachments

+ + + + +
+ @if ((d.attachments ?? []).length === 0) { +

+ No attachments yet. +

+ } @else { +
    + @for (attachment of d.attachments ?? []; track attachment.id) { +
  • + + {{ attachment.fileName }} + ยท {{ attachment.byteSize | number }} B + + + + + + + + + +
  • + } +
+ } +
+

Subtasks

diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts index 84e716ee9..1efaeb15b 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts @@ -61,6 +61,7 @@ import { type TicketCreationTemplate, type TicketPriority, type TicketResponseDto, + type AttachmentResponseDto, type TicketStatus, type UpdateTicketAutomationDto, } from '@forepath/agenstra/frontend/data-access-agent-console'; @@ -2637,6 +2638,47 @@ export class TicketsBoardComponent implements OnInit { this.ticketsFacade.update(d.id, { content: draft }); } + onTicketAttachmentSelected(event: Event, ticket: TicketResponseDto): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + + if (files.length === 0) { + return; + } + + for (const file of files) { + this.ticketsService + .uploadAttachment(ticket.id, file) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: (attachment) => this.ticketsFacade.addAttachment(ticket.id, attachment) }); + } + + input.value = ''; + } + + downloadTicketAttachment(attachment: AttachmentResponseDto): void { + this.ticketsService + .downloadAttachment(attachment) + .pipe(take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe((blob) => this.saveBlob(blob, attachment.fileName)); + } + + deleteTicketAttachment(ticket: TicketResponseDto, attachment: AttachmentResponseDto): void { + this.ticketsService + .deleteAttachment(ticket.id, attachment.id) + .pipe(take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.ticketsFacade.removeAttachment(ticket.id, attachment.id)); + } + + private saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } + isLaneDragHighlight(status: BoardLaneStatus): boolean { const dragged = this.draggedTicket(); diff --git a/libs/domains/decabill/backend/feature-billing-manager/spec/asyncapi.yaml b/libs/domains/decabill/backend/feature-billing-manager/spec/asyncapi.yaml index f67e56041..b32c69f2b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/asyncapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/asyncapi.yaml @@ -455,6 +455,29 @@ components: createdByUserId: { type: string, format: uuid, nullable: true } createdByEmail: { type: string } locked: { type: boolean } + attachments: + type: array + items: + type: object + required: + [ + id, + ticketId, + fileName, + contentType, + byteSize, + downloadUrl, + createdAt, + ] + properties: + id: { type: string, format: uuid } + ticketId: { type: string, format: uuid } + fileName: { type: string } + contentType: { type: string } + byteSize: { type: integer } + downloadUrl: { type: string } + createdByUserId: { type: string, format: uuid, nullable: true } + createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } children: diff --git a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml index 2ae7a903d..c5ea053d1 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -6266,6 +6266,80 @@ paths: application/json: schema: $ref: '#/components/schemas/ProjectTicketCommentResponse' + /projects/{projectId}/tickets/{ticketId}/attachments: + get: + tags: [Project Board] + summary: List ticket attachments + description: Requires `attachments:read` and project read access. + operationId: listProjectTicketAttachments + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/TicketId' + responses: + '200': + description: Attachment list + content: + application/json: + schema: + type: array + items: + { + $ref: '#/components/schemas/ProjectTicketAttachmentResponse', + } + post: + tags: [Project Board] + summary: Upload ticket attachment + description: Requires `attachments:write` and project write access. Multipart field name is `file`. + operationId: uploadProjectTicketAttachment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/TicketId' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + responses: + '201': + description: Uploaded attachment + content: + application/json: + schema: + { $ref: '#/components/schemas/ProjectTicketAttachmentResponse' } + /projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}: + delete: + tags: [Project Board] + summary: Delete ticket attachment metadata + description: Requires `attachments:write` and project write access. + operationId: deleteProjectTicketAttachment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/TicketId' + - name: attachmentId + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': { description: Deleted } + /projects/{projectId}/tickets/{ticketId}/attachments/{attachmentId}/download: + get: + tags: [Project Board] + summary: Download ticket attachment + description: Requires `attachments:read` and project read access. + operationId: downloadProjectTicketAttachment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/TicketId' + - name: attachmentId + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': { description: Attachment bytes } /projects/{projectId}/milestones: get: tags: [Projects] @@ -10738,6 +10812,10 @@ components: createdByUserId: { type: string, format: uuid, nullable: true } createdByEmail: { type: string } locked: { type: boolean } + attachments: + type: array + items: + { $ref: '#/components/schemas/ProjectTicketAttachmentResponse' } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } children: @@ -10773,6 +10851,19 @@ components: userEmail: { type: string } body: { type: string } createdAt: { type: string, format: date-time } + ProjectTicketAttachmentResponse: + type: object + required: + [id, ticketId, fileName, contentType, byteSize, downloadUrl, createdAt] + properties: + id: { type: string, format: uuid } + ticketId: { type: string, format: uuid } + fileName: { type: string } + contentType: { type: string } + byteSize: { type: integer } + downloadUrl: { type: string } + createdByUserId: { type: string, format: uuid, nullable: true } + createdAt: { type: string, format: date-time } CreateProjectTicketCommentDto: type: object required: [body] diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts index 9471d0844..564b6e7f0 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts @@ -162,6 +162,7 @@ export * from './lib/entities/usage-record.entity'; export * from './lib/projects/entities/project.entity'; export * from './lib/projects/entities/project-milestone.entity'; export * from './lib/projects/entities/project-ticket.entity'; +export * from './lib/projects/entities/project-ticket-attachment.entity'; export * from './lib/projects/entities/project-ticket-comment.entity'; export * from './lib/projects/entities/project-ticket-activity.entity'; export * from './lib/projects/entities/project-time-entry.entity'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.spec.ts index 1ab7ef760..3cae47913 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.spec.ts @@ -22,6 +22,8 @@ describe('BILLING_PAT_SCOPES', () => { 'projects:write', 'tickets:read', 'tickets:write', + 'attachments:read', + 'attachments:write', 'milestones:write', 'time_entries:write', 'billing_admin:read', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts index c4f6a96c6..19326a60e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts @@ -25,6 +25,8 @@ export const BILLING_PAT_SCOPES = [ 'projects:write', 'tickets:read', 'tickets:write', + 'attachments:read', + 'attachments:write', 'milestones:write', 'time_entries:write', 'billing_admin:read', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts index cf8cc789e..e45408110 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts @@ -53,6 +53,7 @@ import { CustomerAutoBillingController } from './controllers/customer-auto-billi import { AdminProjectsController } from './projects/controllers/admin-projects.controller'; import { ProjectMilestonesController } from './projects/controllers/project-milestones.controller'; import { ProjectTicketsController } from './projects/controllers/project-tickets.controller'; +import { ProjectTicketAttachmentsController } from './projects/controllers/project-ticket-attachments.controller'; import { ProjectTimeEntriesController } from './projects/controllers/project-time-entries.controller'; import { ProjectsController } from './projects/controllers/projects.controller'; import { ProjectBoardGateway } from './projects/gateways/project-board.gateway'; @@ -61,6 +62,7 @@ import { ProjectMilestoneEntity } from './projects/entities/project-milestone.en import { ProjectTicketActivityEntity } from './projects/entities/project-ticket-activity.entity'; import { ProjectTicketCommentEntity } from './projects/entities/project-ticket-comment.entity'; import { ProjectTicketEntity } from './projects/entities/project-ticket.entity'; +import { ProjectTicketAttachmentEntity } from './projects/entities/project-ticket-attachment.entity'; import { ProjectTimeEntryEntity } from './projects/entities/project-time-entry.entity'; import { ProjectMilestonesRepository } from './projects/repositories/project-milestones.repository'; import { ProjectTicketActivitiesRepository } from './projects/repositories/project-ticket-activities.repository'; @@ -76,6 +78,7 @@ import { ProjectBoardRealtimeService } from './projects/services/project-board-r import { ProjectBoardSummaryService } from './projects/services/project-board-summary.service'; import { ProjectMilestonesService } from './projects/services/project-milestones.service'; import { ProjectTicketsService } from './projects/services/project-tickets.service'; +import { ProjectTicketAttachmentsService } from './projects/services/project-ticket-attachments.service'; import { ProjectTimeEntriesService } from './projects/services/project-time-entries.service'; import { ProjectsAdminService } from './projects/services/projects-admin.service'; import { ProjectsService } from './projects/services/projects.service'; @@ -421,6 +424,7 @@ const authMethod = getAuthenticationMethod(); ProjectEntity, ProjectMilestoneEntity, ProjectTicketEntity, + ProjectTicketAttachmentEntity, ProjectTicketCommentEntity, ProjectTicketActivityEntity, ProjectTimeEntryEntity, @@ -467,6 +471,7 @@ const authMethod = getAuthenticationMethod(); AdminProjectsController, ProjectMilestonesController, ProjectTicketsController, + ProjectTicketAttachmentsController, ProjectTimeEntriesController, ], providers: [ @@ -643,6 +648,7 @@ const authMethod = getAuthenticationMethod(); ProjectsAdminService, ProjectMilestonesService, ProjectTicketsService, + ProjectTicketAttachmentsService, ProjectTimeEntriesService, ProjectBillingService, ProjectTimeReportService, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts index 8c0736e51..75dc9ba22 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts @@ -24,6 +24,8 @@ describe('BILLING_NOTIFICATION_EVENTS', () => { 'ticket.updated', 'ticket.deleted', 'ticket.comment.created', + 'ticket.attachment.created', + 'ticket.attachment.deleted', 'datev_export.started', 'datev_export.completed', 'datev_export.failed', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts index 607542be5..0746daeda 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts @@ -78,6 +78,8 @@ export const BILLING_NOTIFICATION_EVENTS = [ 'ticket.updated', 'ticket.deleted', 'ticket.comment.created', + 'ticket.attachment.created', + 'ticket.attachment.deleted', 'datev_export.started', 'datev_export.completed', 'datev_export.failed', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts new file mode 100644 index 000000000..7b7d2e908 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-ticket-attachments.controller.ts @@ -0,0 +1,86 @@ +import { RequireScopes } from '@forepath/identity/backend'; +import { + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Req, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; + +import type { RequestWithUser } from '../../utils/billing-access.utils'; +import { ProjectTicketAttachmentResponseDto } from '../dto/project-ticket-attachment.dto'; +import { + ProjectTicketAttachmentsService, + type UploadedProjectTicketAttachment, +} from '../services/project-ticket-attachments.service'; + +function contentDispositionAttachment(filename: string): string { + const fallback = filename.replace(/[^A-Za-z0-9._-]/g, '_') || 'attachment'; + const encoded = encodeURIComponent(filename).replace(/['()]/g, escape).replace(/\*/g, '%2A'); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} + +@Controller('projects/:projectId/tickets/:ticketId/attachments') +export class ProjectTicketAttachmentsController { + constructor(private readonly attachments: ProjectTicketAttachmentsService) {} + + @RequireScopes('attachments:read') + @Get() + async list( + @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, + @Param('ticketId', new ParseUUIDPipe({ version: '4' })) ticketId: string, + @Req() req: RequestWithUser, + ): Promise { + return await this.attachments.list(projectId, ticketId, req); + } + + @RequireScopes('attachments:write') + @Post() + @UseInterceptors(FileInterceptor('file')) + async upload( + @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, + @Param('ticketId', new ParseUUIDPipe({ version: '4' })) ticketId: string, + @UploadedFile() file: UploadedProjectTicketAttachment, + @Req() req: RequestWithUser, + ): Promise { + return await this.attachments.upload(projectId, ticketId, file, req); + } + + @RequireScopes('attachments:write') + @Delete(':attachmentId') + @HttpCode(HttpStatus.NO_CONTENT) + async delete( + @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, + @Param('ticketId', new ParseUUIDPipe({ version: '4' })) ticketId: string, + @Param('attachmentId', new ParseUUIDPipe({ version: '4' })) attachmentId: string, + @Req() req: RequestWithUser, + ): Promise { + await this.attachments.delete(projectId, ticketId, attachmentId, req); + } + + @RequireScopes('attachments:read') + @Get(':attachmentId/download') + async download( + @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, + @Param('ticketId', new ParseUUIDPipe({ version: '4' })) ticketId: string, + @Param('attachmentId', new ParseUUIDPipe({ version: '4' })) attachmentId: string, + @Req() req: RequestWithUser, + @Res({ passthrough: true }) res: Response, + ): Promise { + const result = await this.attachments.download(projectId, ticketId, attachmentId, req); + res.setHeader('Content-Type', result.meta.contentType); + res.setHeader('Content-Length', String(result.buffer.length)); + res.setHeader('Content-Disposition', contentDispositionAttachment(result.meta.fileName)); + return new StreamableFile(result.buffer); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket-attachment.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket-attachment.dto.ts new file mode 100644 index 000000000..17c1452f9 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket-attachment.dto.ts @@ -0,0 +1,10 @@ +export class ProjectTicketAttachmentResponseDto { + id!: string; + ticketId!: string; + fileName!: string; + contentType!: string; + byteSize!: number; + downloadUrl!: string; + createdByUserId?: string | null; + createdAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket.dto.ts index 8c500cfd5..17bd7b6d3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/dto/project-ticket.dto.ts @@ -1,6 +1,7 @@ import { IsEnum, IsOptional, IsString, IsUUID, Length, MaxLength } from 'class-validator'; import { ProjectTicketPriority, ProjectTicketStatus } from '../entities/project.enums'; +import { ProjectTicketAttachmentResponseDto } from './project-ticket-attachment.dto'; export class CreateProjectTicketDto { @IsOptional() @@ -80,6 +81,7 @@ export class ProjectTicketResponseDto { createdByUserId?: string | null; createdByEmail?: string; locked!: boolean; + attachments?: ProjectTicketAttachmentResponseDto[]; createdAt!: Date; updatedAt!: Date; children?: ProjectTicketResponseDto[]; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-attachment.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-attachment.entity.ts new file mode 100644 index 000000000..19da9410a --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project-ticket-attachment.entity.ts @@ -0,0 +1,43 @@ +import { UserEntity } from '@forepath/identity/backend'; +import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; + +import { ProjectTicketEntity } from './project-ticket.entity'; + +@Entity('billing_project_ticket_attachments') +@Index('IDX_billing_project_ticket_attachments_ticket', ['ticketId']) +export class ProjectTicketAttachmentEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'ticket_id' }) + ticketId!: string; + + @ManyToOne(() => ProjectTicketEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'ticket_id' }) + ticket!: ProjectTicketEntity; + + @Column({ type: 'varchar', length: 255, name: 'file_name' }) + fileName!: string; + + @Column({ type: 'varchar', length: 255, name: 'content_type' }) + contentType!: string; + + @Column({ type: 'bigint', name: 'byte_size' }) + byteSize!: string; + + @Column({ type: 'varchar', length: 512, name: 'storage_key' }) + storageKey!: string; + + @Column({ type: 'uuid', nullable: true, name: 'stored_file_id' }) + storedFileId?: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'created_by_user_id' }) + createdByUserId?: string | null; + + @ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'created_by_user_id' }) + createdByUser?: UserEntity | null; + + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) + createdAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project.enums.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project.enums.ts index 57faa3312..30e210dde 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project.enums.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/entities/project.enums.ts @@ -35,6 +35,8 @@ export enum ProjectTicketActionType { MILESTONE_CHANGED = 'MILESTONE_CHANGED', DELETED = 'DELETED', COMMENT_ADDED = 'COMMENT_ADDED', + ATTACHMENT_ADDED = 'ATTACHMENT_ADDED', + ATTACHMENT_REMOVED = 'ATTACHMENT_REMOVED', LOCKED = 'LOCKED', } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.spec.ts new file mode 100644 index 000000000..b6bce8a0c --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.spec.ts @@ -0,0 +1,76 @@ +import { BadRequestException } from '@nestjs/common'; +import { UserRole } from '@forepath/identity/backend'; + +import { ProjectTicketActionType } from '../entities/project.enums'; +import { ProjectTicketAttachmentsService } from './project-ticket-attachments.service'; + +jest.mock('../../utils/tenant-query.utils', () => ({ getRequiredTenantId: jest.fn(() => 'tenant-1') })); + +describe('ProjectTicketAttachmentsService', () => { + const project = { id: 'project-1', userId: 'user-1' }; + const ticket = { id: 'ticket-1', projectId: 'project-1', locked: false }; + const req = { user: { id: 'user-1', roles: [UserRole.ADMIN] } } as never; + const projectsRepository = { findByIdOrThrow: jest.fn() }; + const ticketsRepository = { findByIdOrThrow: jest.fn() }; + const activitiesRepository = { create: jest.fn() }; + const attachmentsRepo = { + create: jest.fn((row) => row), + save: jest.fn(async (row) => ({ ...row, createdAt: new Date('2026-01-01T00:00:00.000Z') })), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + const fileStorage = { writeProjectTicketAttachmentFile: jest.fn(), readProjectTicketAttachmentFile: jest.fn() }; + const storedFileRegistry = { registerFromBuffer: jest.fn(async () => ({ id: 'stored-1' })) }; + const notificationPublisher = { publish: jest.fn() }; + const service = new ProjectTicketAttachmentsService( + projectsRepository as never, + ticketsRepository as never, + activitiesRepository as never, + attachmentsRepo as never, + fileStorage as never, + storedFileRegistry as never, + notificationPublisher as never, + ); + + beforeEach(() => { + jest.clearAllMocks(); + projectsRepository.findByIdOrThrow.mockResolvedValue(project); + ticketsRepository.findByIdOrThrow.mockResolvedValue(ticket); + }); + + it('stores uploaded ticket attachments in file storage and registry', async () => { + const result = await service.upload( + 'project-1', + 'ticket-1', + { + buffer: Buffer.from('hello'), + originalname: '../brief.txt', + mimetype: 'text/plain', + }, + req, + ); + + expect(result.fileName).toBe('brief.txt'); + expect(fileStorage.writeProjectTicketAttachmentFile).toHaveBeenCalledWith( + expect.stringContaining('user-1/project-1/ticket-1/'), + Buffer.from('hello'), + ); + expect(storedFileRegistry.registerFromBuffer).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + expect(activitiesRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ actionType: ProjectTicketActionType.ATTACHMENT_ADDED }), + ); + expect(notificationPublisher.publish).toHaveBeenCalledWith( + 'ticket.attachment.created', + expect.any(Object), + 'user-1', + ); + }); + + it('rejects missing attachment bytes', async () => { + await expect(service.upload('project-1', 'ticket-1', undefined, req)).rejects.toBeInstanceOf(BadRequestException); + expect(fileStorage.writeProjectTicketAttachmentFile).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts new file mode 100644 index 000000000..377d8204a --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-ticket-attachments.service.ts @@ -0,0 +1,181 @@ +import { randomUUID } from 'crypto'; +import * as path from 'path'; + +import { FileStorageScope, FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { BadRequestException, Injectable, NotFoundException, PayloadTooLargeException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { BillingNotificationPublisher } from '../../notifications/billing-notification.publisher'; +import { StoredFileRegistryService } from '../../services/stored-file-registry.service'; +import { getRequiredTenantId } from '../../utils/tenant-query.utils'; +import { ProjectTicketAttachmentResponseDto } from '../dto/project-ticket-attachment.dto'; +import { ProjectTicketAttachmentEntity } from '../entities/project-ticket-attachment.entity'; +import { ProjectTicketActionType, ProjectTicketActorType } from '../entities/project.enums'; +import { ProjectTicketActivitiesRepository } from '../repositories/project-ticket-activities.repository'; +import { ProjectTicketsRepository } from '../repositories/project-tickets.repository'; +import { ProjectsRepository } from '../repositories/projects.repository'; +import { ensureProjectAdmin, ensureProjectReadable, ensureTicketUnlocked } from '../utils/project-access.utils'; +import type { RequestWithUser } from '../../utils/billing-access.utils'; +import { getUserFromRequest } from '../../utils/billing-access.utils'; + +export type UploadedProjectTicketAttachment = { + buffer: Buffer; + size?: number; + originalname?: string; + mimetype?: string; +}; + +const MAX_ATTACHMENT_BYTES = Number.parseInt( + process.env.DECABILL_TICKET_ATTACHMENT_MAX_BYTES || `${25 * 1024 * 1024}`, + 10, +); + +@Injectable() +export class ProjectTicketAttachmentsService { + constructor( + private readonly projectsRepository: ProjectsRepository, + private readonly ticketsRepository: ProjectTicketsRepository, + private readonly activitiesRepository: ProjectTicketActivitiesRepository, + @InjectRepository(ProjectTicketAttachmentEntity) + private readonly attachmentsRepo: Repository, + private readonly fileStorage: FileStorageService, + private readonly storedFileRegistry: StoredFileRegistryService, + private readonly notificationPublisher: BillingNotificationPublisher, + ) {} + + async list(projectId: string, ticketId: string, req: RequestWithUser): Promise { + await this.assertReadable(projectId, ticketId, req); + const rows = await this.attachmentsRepo.find({ where: { ticketId }, order: { createdAt: 'ASC' } }); + return rows.map((row) => this.toDto(row, projectId)); + } + + async upload( + projectId: string, + ticketId: string, + file: UploadedProjectTicketAttachment | undefined, + req: RequestWithUser, + ) { + const { project, ticket, userInfo } = await this.assertWritable(projectId, ticketId, req); + if (!file?.buffer?.length) throw new BadRequestException('Attachment file is required'); + const size = file.size ?? file.buffer.byteLength; + if (size > MAX_ATTACHMENT_BYTES) throw new PayloadTooLargeException('Attachment exceeds maximum size'); + const fileName = this.sanitizeFileName(file.originalname || 'attachment'); + const id = randomUUID(); + const storageKey = `${project.userId}/${projectId}/${ticketId}/${id}/${fileName}`; + await this.fileStorage.writeProjectTicketAttachmentFile(storageKey, file.buffer); + const stored = await this.storedFileRegistry.registerFromBuffer({ + tenantId: getRequiredTenantId(), + scope: FileStorageScope.projectTicketAttachments, + storageKey, + content: file.buffer, + }); + const row = await this.attachmentsRepo.save( + this.attachmentsRepo.create({ + id, + ticketId: ticket.id, + fileName, + contentType: file.mimetype || 'application/octet-stream', + byteSize: String(size), + storageKey, + storedFileId: stored.id, + createdByUserId: userInfo.userId ?? null, + }), + ); + await this.activitiesRepository.create({ + ticketId, + actorType: ProjectTicketActorType.HUMAN, + actorUserId: userInfo.userId ?? null, + actionType: ProjectTicketActionType.ATTACHMENT_ADDED, + payload: { attachmentId: row.id, fileName: row.fileName, byteSize: Number(row.byteSize) }, + }); + const dto = this.toDto(row, projectId); + this.notificationPublisher.publish('ticket.attachment.created', { ...dto, projectId }, project.userId); + return dto; + } + + async delete(projectId: string, ticketId: string, attachmentId: string, req: RequestWithUser): Promise { + const { project, userInfo } = await this.assertWritable(projectId, ticketId, req); + const row = await this.attachmentsRepo.findOne({ where: { id: attachmentId, ticketId } }); + if (!row) throw new NotFoundException('Attachment not found'); + await this.attachmentsRepo.remove(row); + await this.activitiesRepository.create({ + ticketId, + actorType: ProjectTicketActorType.HUMAN, + actorUserId: userInfo.userId ?? null, + actionType: ProjectTicketActionType.ATTACHMENT_REMOVED, + payload: { attachmentId: row.id, fileName: row.fileName, byteSize: Number(row.byteSize) }, + }); + this.notificationPublisher.publish( + 'ticket.attachment.deleted', + { ...this.toDto(row, projectId), projectId }, + project.userId, + ); + } + + async download(projectId: string, ticketId: string, attachmentId: string, req: RequestWithUser) { + await this.assertReadable(projectId, ticketId, req); + const row = await this.attachmentsRepo.findOne({ where: { id: attachmentId, ticketId } }); + if (!row) throw new NotFoundException('Attachment not found'); + return { + meta: this.toDto(row, projectId), + buffer: await this.fileStorage.readProjectTicketAttachmentFile(row.storageKey), + }; + } + + async listForTicketIds( + ticketIds: string[], + projectId: string, + ): Promise> { + const map = new Map(); + if (!ticketIds.length) return map; + const rows = await this.attachmentsRepo + .createQueryBuilder('a') + .where('a.ticket_id IN (:...ticketIds)', { ticketIds }) + .orderBy('a.created_at', 'ASC') + .getMany(); + for (const row of rows) { + const current = map.get(row.ticketId) ?? []; + current.push(this.toDto(row, projectId)); + map.set(row.ticketId, current); + } + return map; + } + + private async assertReadable(projectId: string, ticketId: string, req: RequestWithUser) { + const project = await this.projectsRepository.findByIdOrThrow(projectId); + const userInfo = getUserFromRequest(req); + const ticket = await this.ticketsRepository.findByIdOrThrow(ticketId); + if (ticket.projectId !== projectId) throw new NotFoundException('Attachment not found'); + ensureProjectReadable(userInfo, project); + return { project, ticket, userInfo }; + } + + private async assertWritable(projectId: string, ticketId: string, req: RequestWithUser) { + const ctx = await this.assertReadable(projectId, ticketId, req); + ensureProjectAdmin(ctx.userInfo); + ensureTicketUnlocked(ctx.ticket); + return ctx; + } + + private sanitizeFileName(name: string): string { + const base = path + .basename(name.replace(/\\/g, '/')) + .replace(/[\0\r\n]/g, '') + .trim(); + return (base || 'attachment').slice(0, 255); + } + + private toDto(row: ProjectTicketAttachmentEntity, projectId: string): ProjectTicketAttachmentResponseDto { + return { + id: row.id, + ticketId: row.ticketId, + fileName: row.fileName, + contentType: row.contentType, + byteSize: Number(row.byteSize), + downloadUrl: `/api/projects/${projectId}/tickets/${row.ticketId}/attachments/${row.id}/download`, + createdByUserId: row.createdByUserId ?? null, + createdAt: row.createdAt, + }; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.spec.ts index eb041e1eb..03808bfa3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.spec.ts @@ -23,12 +23,14 @@ describe('ProjectTicketsService', () => { publishTicket: jest.fn(), publishTicketComment: jest.fn(), }; + const ticketAttachmentsService = { listForTicketIds: jest.fn().mockResolvedValue(new Map()) }; let service: ProjectTicketsService; const billingSearchIndexService = { scheduleUpsert: jest.fn(), scheduleDelete: jest.fn() }; beforeEach(() => { jest.resetAllMocks(); + ticketAttachmentsService.listForTicketIds.mockResolvedValue(new Map()); service = new ProjectTicketsService( projectsRepository as never, ticketsRepository as never, @@ -40,6 +42,7 @@ describe('ProjectTicketsService', () => { projectBoardSummary as never, billingNotificationPublisher as never, billingSearchIndexService as never, + ticketAttachmentsService as never, ); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts index 19cfa3b83..cd6ba9a09 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-tickets.service.ts @@ -7,6 +7,7 @@ import { BillingNotificationPublisher } from '../../notifications/billing-notifi import { mapTicketToSearchDocument } from '../../search/billing-search-document.mapper'; import { BillingSearchIndexService } from '../../search/billing-search-index.service'; import { getRequiredTenantId } from '../../utils/tenant-query.utils'; +import type { ProjectTicketAttachmentResponseDto } from '../dto/project-ticket-attachment.dto'; import type { CreateProjectTicketCommentDto, CreateProjectTicketDto, @@ -41,6 +42,7 @@ import { import { ProjectBoardRealtimeService } from './project-board-realtime.service'; import { PROJECTS_BOARD_EVENTS } from './project-board-realtime.constants'; import { ProjectBoardSummaryService } from './project-board-summary.service'; +import { ProjectTicketAttachmentsService } from './project-ticket-attachments.service'; @Injectable() export class ProjectTicketsService { @@ -55,6 +57,7 @@ export class ProjectTicketsService { private readonly projectBoardSummary: ProjectBoardSummaryService, private readonly billingNotificationPublisher: BillingNotificationPublisher, private readonly billingSearchIndexService: BillingSearchIndexService, + private readonly ticketAttachmentsService: ProjectTicketAttachmentsService, ) {} async listTickets( @@ -75,7 +78,12 @@ export class ProjectTicketsService { const all = await this.ticketsRepository.findAllByProject(projectId); const descMap = buildDescendantCheckboxTaskTotalsByTicketId(all); - return Promise.all(rows.map((row) => this.mapTicket(row, descMap))); + const attachmentMap = await this.ticketAttachmentsService.listForTicketIds( + rows.map((row) => row.id), + projectId, + ); + + return Promise.all(rows.map((row) => this.mapTicket(row, descMap, attachmentMap))); } async findOne( @@ -95,10 +103,14 @@ export class ProjectTicketsService { const all = await this.ticketsRepository.findAllByProject(projectId); const descMap = buildDescendantCheckboxTaskTotalsByTicketId(all); - const dto = await this.mapTicket(ticket, descMap); + const attachmentMap = await this.ticketAttachmentsService.listForTicketIds( + all.map((row) => row.id), + projectId, + ); + const dto = await this.mapTicket(ticket, descMap, attachmentMap); if (includeDescendants) { - dto.children = await this.buildChildTicketDtos(ticket.id, all, descMap); + dto.children = await this.buildChildTicketDtos(ticket.id, all, descMap, attachmentMap); } return dto; @@ -389,6 +401,7 @@ export class ProjectTicketsService { parentTicketId: string, all: ProjectTicketEntity[], descMap: Map, + attachmentMap: Map, ): Promise { const byParent = new Map(); @@ -407,7 +420,7 @@ export class ProjectTicketsService { const out: ProjectTicketResponseDto[] = []; for (const k of kids) { - const d = await this.mapTicket(k, descMap); + const d = await this.mapTicket(k, descMap, attachmentMap); d.children = await build(k.id); out.push(d); @@ -422,6 +435,7 @@ export class ProjectTicketsService { private async mapTicket( row: ProjectTicketEntity, descMap: Map, + attachmentMap?: Map, ): Promise { const own = countMarkdownCheckboxTasks(row.content); const children = descMap.get(row.id) ?? { open: 0, done: 0 }; @@ -455,6 +469,7 @@ export class ProjectTicketsService { createdByUserId: row.createdByUserId, createdByEmail, locked: row.locked, + attachments: attachmentMap?.get(row.id) ?? [], createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.spec.ts index 3feaf0876..42eccf4f0 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.spec.ts @@ -82,7 +82,7 @@ describe('AdminFileManagerService', () => { it('lists scope roots for tenant view', async () => { const result = await runWithTenantId('default', () => service.listDirectory('', AdminFileManagerView.TENANT)); - expect(result.entries.map((entry) => entry.name)).toEqual(['customer', 'export', 'supplier']); + expect(result.entries.map((entry) => entry.name)).toEqual(['customer', 'export', 'project', 'supplier']); expect(result.viewTenantId).toBe('default'); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts index e8d8b98f3..9dca5cc9c 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts @@ -344,6 +344,7 @@ export class AdminFileManagerService { const timesheetSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerTimesheets]; const supplierSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.supplierInvoices]; const datevSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.datevExports]; + const ticketAttachmentSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.projectTicketAttachments]; const invoiceRows: Array<{ pdf_storage_key: string | null; @@ -510,6 +511,31 @@ export class AdminFileManagerService { ); } + const attachmentRows: Array<{ storage_key: string; created_at: Date }> = await this.dataSource.query( + ` + SELECT a.storage_key, a.created_at + FROM billing_project_ticket_attachments a + INNER JOIN billing_project_tickets t ON t.id = a.ticket_id + INNER JOIN billing_projects p ON p.id = t.project_id + INNER JOIN users u ON u.id = p.user_id + WHERE u.tenant_id = $1 + `, + [tenantId], + ); + + for (const row of attachmentRows) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + ticketAttachmentSegment, + FileStorageScope.projectTicketAttachments, + row.storage_key, + row.created_at, + ), + ); + } + return refs; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.spec.ts index 1ccad8990..2a95472ea 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.spec.ts @@ -55,7 +55,12 @@ describe('admin-file-manager-path.util', () => { }); it('materializes fixed structural children without dynamic date folders', () => { - expect(structuralChildrenForPath([], AdminFileManagerView.TENANT)).toEqual(['customer', 'supplier', 'export']); + expect(structuralChildrenForPath([], AdminFileManagerView.TENANT)).toEqual([ + 'customer', + 'supplier', + 'export', + 'project', + ]); expect(structuralChildrenForPath(['customer'], AdminFileManagerView.TENANT)).toEqual([ 'invoices', 'offers', @@ -63,6 +68,8 @@ describe('admin-file-manager-path.util', () => { ]); expect(structuralChildrenForPath(['supplier'], AdminFileManagerView.TENANT)).toEqual(['invoices']); expect(structuralChildrenForPath(['export'], AdminFileManagerView.TENANT)).toEqual(['datev']); + expect(structuralChildrenForPath(['project'], AdminFileManagerView.TENANT)).toEqual(['tickets']); + expect(structuralChildrenForPath(['project', 'tickets'], AdminFileManagerView.TENANT)).toEqual(['attachments']); expect(structuralChildrenForPath(['export', 'datev'], AdminFileManagerView.TENANT)).toBeNull(); expect(structuralChildrenForPath(['customer', 'invoices'], AdminFileManagerView.TENANT)).toBeNull(); expect(structuralChildrenForPath([], AdminFileManagerView.UNIFIED)).toBeNull(); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.ts index b8425e9a1..c4bca1a1f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.ts @@ -16,13 +16,14 @@ const SEGMENT_TO_SCOPE: Readonly> = { [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerTimesheets]]: FileStorageScope.customerTimesheets, [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.supplierInvoices]]: FileStorageScope.supplierInvoices, [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.datevExports]]: FileStorageScope.datevExports, + [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.projectTicketAttachments]]: FileStorageScope.projectTicketAttachments, }; /** Full scope path segments (e.g. customer/invoices). */ export const ADMIN_FILE_MANAGER_SCOPE_SEGMENTS = Object.keys(SEGMENT_TO_SCOPE); /** Top-level virtual folders matching the disk narrative. */ -export const ADMIN_FILE_MANAGER_ROOT_FOLDERS = ['customer', 'supplier', 'export'] as const; +export const ADMIN_FILE_MANAGER_ROOT_FOLDERS = ['customer', 'supplier', 'export', 'project'] as const; /** * Fixed structural children under a path (tenant-relative). @@ -33,6 +34,8 @@ export const ADMIN_FILE_MANAGER_STRUCTURAL_CHILDREN: Readonly { + let service: ProjectTicketsService; + let httpMock: HttpTestingController; + const apiUrl = 'http://localhost:3200/api'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: ENVIRONMENT, + useValue: { + billing: { + urls: { + restApi: apiUrl, + }, + }, + }, + }, + ], + }); + service = TestBed.inject(ProjectTicketsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('listAttachments should GET project ticket attachments', (done) => { + const attachment = attachmentFixture(); + + service.listAttachments('project-1', 'ticket-1').subscribe((rows) => { + expect(rows).toEqual([attachment]); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/projects/project-1/tickets/ticket-1/attachments`); + + expect(req.request.method).toBe('GET'); + req.flush([attachment]); + }); + + it('uploadAttachment should POST multipart form data', (done) => { + const attachment = attachmentFixture(); + const file = new File(['hello'], 'brief.txt', { type: 'text/plain' }); + + service.uploadAttachment('project-1', 'ticket-1', file).subscribe((row) => { + expect(row).toEqual(attachment); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/projects/project-1/tickets/ticket-1/attachments`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body instanceof FormData).toBe(true); + req.flush(attachment); + }); + + it('downloadAttachment should GET project ticket attachment as blob', (done) => { + service.downloadAttachment('project-1', 'ticket-1', 'att-1').subscribe((blob) => { + expect(blob.size).toBe(5); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/projects/project-1/tickets/ticket-1/attachments/att-1/download`); + + expect(req.request.method).toBe('GET'); + expect(req.request.responseType).toBe('blob'); + req.flush(new Blob(['hello'])); + }); + + it('deleteAttachment should DELETE project ticket attachment metadata', (done) => { + service.deleteAttachment('project-1', 'ticket-1', 'att-1').subscribe(() => done()); + const req = httpMock.expectOne(`${apiUrl}/projects/project-1/tickets/ticket-1/attachments/att-1`); + + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); +}); + +function attachmentFixture() { + return { + id: 'att-1', + ticketId: 'ticket-1', + fileName: 'brief.txt', + contentType: 'text/plain', + byteSize: 5, + downloadUrl: '/api/projects/project-1/tickets/ticket-1/attachments/att-1/download', + createdByUserId: null, + createdAt: '2026-01-01T00:00:00Z', + }; +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/project-tickets.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/project-tickets.service.ts index 4aa3b1702..f6add5506 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/project-tickets.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/project-tickets.service.ts @@ -9,6 +9,7 @@ import type { CreateProjectTicketDto, ListProjectTicketsParams, ProjectTicketActivityResponse, + ProjectTicketAttachmentResponse, ProjectTicketCommentResponse, ProjectTicketResponse, UpdateProjectTicketDto, @@ -95,4 +96,27 @@ export class ProjectTicketsService { params: httpParams, }); } + + listAttachments(projectId: string, ticketId: string): Observable { + return this.http.get(`${this.ticketsUrl(projectId)}/${ticketId}/attachments`); + } + + uploadAttachment(projectId: string, ticketId: string, file: File): Observable { + const form = new FormData(); + form.append('file', file, file.name); + return this.http.post( + `${this.ticketsUrl(projectId)}/${ticketId}/attachments`, + form, + ); + } + + deleteAttachment(projectId: string, ticketId: string, attachmentId: string): Observable { + return this.http.delete(`${this.ticketsUrl(projectId)}/${ticketId}/attachments/${attachmentId}`); + } + + downloadAttachment(projectId: string, ticketId: string, attachmentId: string): Observable { + return this.http.get(`${this.ticketsUrl(projectId)}/${ticketId}/attachments/${attachmentId}/download`, { + responseType: 'blob', + }); + } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.actions.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.actions.ts index 2a55124be..c3bfbf3ad 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.actions.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.actions.ts @@ -4,6 +4,7 @@ import type { CreateProjectTicketDto, ListProjectTicketsParams, ProjectTicketActivityResponse, + ProjectTicketAttachmentResponse, ProjectTicketCommentResponse, ProjectTicketResponse, UpdateProjectTicketDto, @@ -68,6 +69,16 @@ export const addProjectTicketCommentFailure = createAction( props<{ error: string }>(), ); +export const projectTicketAttachmentAdded = createAction( + '[ProjectTickets] Attachment Added', + props<{ ticketId: string; attachment: ProjectTicketAttachmentResponse }>(), +); + +export const projectTicketAttachmentRemoved = createAction( + '[ProjectTickets] Attachment Removed', + props<{ ticketId: string; attachmentId: string }>(), +); + export const clearProjectTicketsError = createAction('[ProjectTickets] Clear Error'); export const projectBoardTicketUpsert = createAction( diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.facade.ts index e3e83102c..76aa05688 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.facade.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.facade.ts @@ -4,6 +4,7 @@ import { Store } from '@ngrx/store'; import type { CreateProjectTicketDto, ListProjectTicketsParams, + ProjectTicketAttachmentResponse, UpdateProjectTicketDto, } from '../../types/projects.types'; @@ -15,6 +16,8 @@ import { deleteProjectTicket, loadProjectTickets, openProjectTicketDetail, + projectTicketAttachmentAdded, + projectTicketAttachmentRemoved, updateProjectTicket, } from './project-tickets.actions'; import { @@ -75,6 +78,14 @@ export class ProjectTicketsFacade { this.store.dispatch(addProjectTicketComment({ projectId, ticketId, body })); } + addAttachment(ticketId: string, attachment: ProjectTicketAttachmentResponse): void { + this.store.dispatch(projectTicketAttachmentAdded({ ticketId, attachment })); + } + + removeAttachment(ticketId: string, attachmentId: string): void { + this.store.dispatch(projectTicketAttachmentRemoved({ ticketId, attachmentId })); + } + clearError(): void { this.store.dispatch(clearProjectTicketsError()); } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.reducer.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.reducer.ts index 27c998ee2..c298b4fdd 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.reducer.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/project-tickets/project-tickets.reducer.ts @@ -29,6 +29,8 @@ import { projectBoardCommentCreated, projectBoardTicketRemoved, projectBoardTicketUpsert, + projectTicketAttachmentAdded, + projectTicketAttachmentRemoved, updateProjectTicket, updateProjectTicketFailure, updateProjectTicketSuccess, @@ -88,6 +90,24 @@ function mergeCreatedChildIntoDetail( return { ...detail, children: [...withoutDup, created] }; } +function updateTicketAttachments( + ticket: ProjectTicketResponse, + ticketId: string, + update: ( + attachments: NonNullable, + ) => NonNullable, +): ProjectTicketResponse { + const nextChildren = ticket.children?.map((child) => updateTicketAttachments(child, ticketId, update)); + + if (ticket.id !== ticketId) return nextChildren === ticket.children ? ticket : { ...ticket, children: nextChildren }; + + return { + ...ticket, + attachments: update(ticket.attachments ?? []), + children: nextChildren ?? ticket.children, + }; +} + export const projectTicketsReducer = createReducer( initialProjectTicketsState, on(loadProjectTickets, (state, { params }) => ({ @@ -180,6 +200,26 @@ export const projectTicketsReducer = createReducer( }; }), on(addProjectTicketCommentFailure, (state, { error }) => ({ ...state, saving: false, error })), + on(projectTicketAttachmentAdded, (state, { ticketId, attachment }) => { + const append = (attachments: NonNullable) => { + const withoutDup = attachments.filter((a) => a.id !== attachment.id); + return [...withoutDup, attachment]; + }; + const list = state.list.map((ticket) => updateTicketAttachments(ticket, ticketId, append)); + const detail = state.detail ? updateTicketAttachments(state.detail, ticketId, append) : state.detail; + const enriched = enrichTicketsWithSubtaskCounts(list, detail); + + return { ...state, list: enriched.list, detail: enriched.detail }; + }), + on(projectTicketAttachmentRemoved, (state, { ticketId, attachmentId }) => { + const remove = (attachments: NonNullable) => + attachments.filter((a) => a.id !== attachmentId); + const list = state.list.map((ticket) => updateTicketAttachments(ticket, ticketId, remove)); + const detail = state.detail ? updateTicketAttachments(state.detail, ticketId, remove) : state.detail; + const enriched = enrichTicketsWithSubtaskCounts(list, detail); + + return { ...state, list: enriched.list, detail: enriched.detail }; + }), on(clearProjectTicketsError, (state) => ({ ...state, error: null })), on(projectBoardTicketUpsert, (state, { ticket }) => { const list = mergeTicketInList(state.list, ticket); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/projects.types.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/projects.types.ts index 76495ee35..4336b72ed 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/projects.types.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/projects.types.ts @@ -144,12 +144,24 @@ export interface ProjectTicketResponse { createdByUserId?: string | null; createdByEmail?: string; locked: boolean; + attachments?: ProjectTicketAttachmentResponse[]; createdAt: string; updatedAt: string; children?: ProjectTicketResponse[]; subtaskCounts?: ProjectTicketSubtaskCountsDto; } +export interface ProjectTicketAttachmentResponse { + id: string; + ticketId: string; + fileName: string; + contentType: string; + byteSize: number; + downloadUrl: string; + createdByUserId?: string | null; + createdAt: string; +} + export interface CreateProjectTicketDto { parentId?: string; milestoneId?: string; diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.html index 4167ff459..4b2fc62fb 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.html @@ -403,6 +403,75 @@

Description

>
+
+
+

Attachments

+ @if (isAdmin && !d.locked) { + + + + + } +
+ @if ((d.attachments ?? []).length === 0) { +

No attachments yet.

+ } @else { +
    + @for (attachment of d.attachments ?? []; track attachment.id) { +
  • + + {{ attachment.fileName }} + ยท {{ attachment.byteSize | number }} B + + + + + + @if (isAdmin && !d.locked) { + + + + } + +
  • + } +
+ } +
+

Subtasks

diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.ts index a588c1190..f4a43d13c 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/project-board/project-board.component.ts @@ -27,6 +27,7 @@ import { type BoardLaneStatus, type CreateProjectTicketDto, type ProjectMilestoneResponse, + type ProjectTicketAttachmentResponse, type ProjectTicketBoardRow, type ProjectTicketPriority, type ProjectTicketResponse, @@ -905,6 +906,47 @@ export class ProjectBoardComponent implements OnInit { this.ticketsFacade.update(this.projectId, d.id, { content }); } + onTicketAttachmentSelected(event: Event, ticket: ProjectTicketResponse): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + + if (files.length === 0) return; + + for (const file of files) { + this.ticketsService + .uploadAttachment(this.projectId, ticket.id, file) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: (attachment) => this.ticketsFacade.addAttachment(ticket.id, attachment) }); + } + + input.value = ''; + } + + downloadTicketAttachment(attachment: ProjectTicketAttachmentResponse, ticket: ProjectTicketResponse): void { + this.ticketsService + .downloadAttachment(this.projectId, ticket.id, attachment.id) + .pipe(take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe((blob) => this.saveBlob(blob, attachment.fileName)); + } + + deleteTicketAttachment(attachment: ProjectTicketAttachmentResponse, ticket: ProjectTicketResponse): void { + if (!this.isAdmin || ticket.locked) return; + + this.ticketsService + .deleteAttachment(this.projectId, ticket.id, attachment.id) + .pipe(take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.ticketsFacade.removeAttachment(ticket.id, attachment.id)); + } + + private saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } + submitComment(): void { const ticketId = this.detail()?.id; const body = this.commentBody().trim(); diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts index b9862f71b..e177ebedf 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts @@ -8,6 +8,8 @@ export const FileStorageScope = { customerTimesheets: 'customerTimesheets', supplierInvoices: 'supplierInvoices', datevExports: 'datevExports', + projectTicketAttachments: 'projectTicketAttachments', + agenstraAttachments: 'agenstraAttachments', } as const; export type FileStorageScope = (typeof FileStorageScope)[keyof typeof FileStorageScope]; @@ -19,6 +21,8 @@ export const FILE_STORAGE_SCOPE_SEGMENTS: Readonly { + await this.writeFile(FileStorageScope.projectTicketAttachments, storageKey, content); + } + + async readProjectTicketAttachmentFile(storageKey: string): Promise { + return await this.readFile(FileStorageScope.projectTicketAttachments, storageKey); + } + + async writeAgenstraAttachmentFile(storageKey: string, content: Buffer): Promise { + await this.writeFile(FileStorageScope.agenstraAttachments, storageKey, content); + } + + async readAgenstraAttachmentFile(storageKey: string): Promise { + return await this.readFile(FileStorageScope.agenstraAttachments, storageKey); + } + private resolveDualReadFallback( scope: FileStorageScopeType, storageKey: string,