From cbff0f9621e1f6c8354518087364880e6addea2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:38:19 -0400 Subject: [PATCH 01/12] [dev] [Marfuen] mariano/more-10 (#1610) * chore(automation): set default title and handle empty integrations in workflow visualizer * chore: add ability to support multiple automations per task * chore(automation): add update and delete functionality for task automations * refactor(tasks): simplify task fetching and remove unused members logic * chore(automation): implement automated evidence collection and related schema * chore(docs): add fraud to risk category enum in openapi schema * refactor(automation): remove organizationId from automation methods * chore: cleanup and add automations * feat(automation): enhance automation overview with latest runs and details * chore: cleanup chat * feat: added chat history * refactor(chat): use ref for automationId to improve state management * chore(automation): implement versioning for automation scripts with API endpoints * chore: update bun lock * chore: update UI * chore(automation): update integrationsUsed type in UnifiedWorkflowCard * chore(deps): add better-auth package and update TypeScript target --------- Co-authored-by: Mariano Fuentes --- .cursor/rules/design-system.mdc | 70 +- ENTERPRISE_API_AUTOMATION_VERSIONING.md | 184 +++++ apps/api/package.json | 1 + apps/api/src/app.module.ts | 1 - .../src/automation/automation.controller.ts | 59 ++ apps/api/src/automation/automation.module.ts | 11 + apps/api/src/automation/automation.service.ts | 64 ++ .../dto/automation-error-responses.dto.ts | 25 + .../dto/automation-responses.dto.ts | 63 ++ .../automation/dto/create-automation.dto.ts | 12 + .../automation/dto/update-automation.dto.ts | 22 + .../schemas/automation-operations.ts | 11 + .../schemas/create-automation.responses.ts | 71 ++ .../schemas/update-automation.responses.ts | 69 ++ .../automations/automations.controller.ts | 233 +++++++ .../tasks/automations/automations.module.ts | 13 + .../tasks/automations/automations.service.ts | 156 +++++ .../dto/automation-error-responses.dto.ts | 25 + .../dto/automation-responses.dto.ts | 63 ++ .../automations/dto/create-automation.dto.ts | 12 + .../automations/dto/update-automation.dto.ts | 22 + .../schemas/automation-operations.ts | 11 + .../schemas/create-automation.responses.ts | 71 ++ .../schemas/update-automation.responses.ts | 69 ++ apps/api/src/tasks/dto/task-responses.dto.ts | 3 +- apps/api/src/tasks/tasks.controller.ts | 63 ++ apps/api/src/tasks/tasks.module.ts | 3 +- apps/api/src/tasks/tasks.service.ts | 38 +- apps/api/tsconfig.json | 2 +- apps/app/next.config.ts | 2 +- apps/app/package.json | 5 +- .../src/app/(app)/[orgId]/frameworks/page.tsx | 4 + .../[orgId]/people/[employeeId]/page.tsx | 8 +- .../people/all/components/TeamMembers.tsx | 8 +- .../actions/task-automation-actions.ts | 151 ++++- .../automation-layout-wrapper.tsx | 0 .../automation/[automationId]/chat.tsx | 147 ++++ .../components/AutomationPageClient.tsx | 19 +- .../components/AutomationSettingsDialogs.tsx | 240 +++++++ .../components/PublishDialog.tsx | 94 +++ .../components/ai-elements/conversation.tsx | 0 .../components/ai-elements/loader.tsx | 0 .../components/chat/ChatBreadcrumb.tsx | 88 +++ .../components/chat/EmptyState.tsx | 71 ++ .../components/chat/message-part/index.tsx | 0 .../chat/message-part/prompt-info.tsx | 16 +- .../chat/message-part/prompt-secret.tsx | 11 +- .../chat/message-part/reasoning.tsx | 0 .../chat/message-part/report-errors.tsx | 0 .../chat/message-part/research-activity.tsx | 0 .../components/chat/message-part/spinner.tsx | 0 .../components/chat/message-part/text.tsx | 0 .../components/chat/message-spinner.tsx | 0 .../components/chat/message.tsx | 0 .../components/chat/tool-header.tsx | 0 .../components/chat/tool-message.tsx | 0 .../components/chat/types.tsx | 0 .../components/icons/github.tsx | 0 .../components/icons/vercel-dashed.tsx | 0 .../components/layout/panels.tsx | 0 .../components/layout/sizing.ts | 0 .../markdown-renderer/markdown-renderer.tsx | 0 .../model-selector/model-selector.tsx | 0 .../model-selector/use-available-models.tsx | 0 .../components/panels/panels.tsx | 0 .../components/preview/preview.tsx | 0 .../components/tabs/index.tsx | 0 .../components/tabs/tab-content.tsx | 0 .../components/tabs/tab-item.tsx | 0 .../components/tabs/use-tab-state.ts | 0 .../components/ui/badge.tsx | 0 .../components/ui/button.tsx | 0 .../components/ui/checkbox.tsx | 0 .../components/ui/dialog.tsx | 0 .../components/ui/input.tsx | 0 .../components/ui/label.tsx | 0 .../components/ui/popover.tsx | 0 .../components/ui/scroll-area.tsx | 0 .../components/ui/select.tsx | 0 .../components/ui/sonner.tsx | 0 .../components/ui/textarea.tsx | 0 .../workflow/components/CodeViewer.tsx | 0 .../workflow/components/ConfettiEffect.tsx | 0 .../workflow/components/EmptyState.tsx | 0 .../workflow/components/TestDialog.tsx | 0 .../workflow/components/TestResultsPanel.tsx | 0 .../components/UnifiedWorkflowCard.tsx | 5 +- .../workflow/components/ViewModeSwitch.tsx | 0 .../workflow/components/WorkflowSkeleton.tsx | 0 .../workflow/components/WorkflowStepCard.tsx | 0 .../components/workflow/components/index.ts | 0 .../components/workflow/types.ts | 0 .../components/workflow/workflow-loading.tsx | 0 .../workflow/workflow-visualizer-simple.tsx | 157 ++++- .../constants/automation-examples.ts | 33 + .../{ => [automationId]}/hooks/index.ts | 2 +- .../hooks/use-automation-versions.ts | 130 ++++ .../[automationId]/hooks/use-chat-handlers.ts | 137 ++++ .../hooks/use-task-automation-analyze.ts} | 30 +- .../hooks/use-task-automation-execution.ts | 16 +- .../hooks/use-task-automation-script.ts | 3 +- .../hooks/use-task-automation.ts | 66 ++ .../{ => [automationId]}/layout.tsx | 5 +- .../[automationId]/lib/chat-context.tsx | 102 +++ .../{ => [automationId]}/lib/deferred.ts | 0 .../{ => [automationId]}/lib/index.ts | 0 .../lib/is-relative-url.ts | 0 .../lib/task-automation-api.ts | 0 .../lib/task-automation-store.ts | 3 +- .../lib/types/data-parts.ts | 0 .../{ => [automationId]}/lib/types/index.ts | 24 +- .../lib/types/metadata.ts | 0 .../automation/[automationId]/page.tsx | 51 ++ .../[automationId]/tools/exa-search.ts | 9 + .../[automationId]/tools/firecrawl.ts | 9 + .../{ => [automationId]}/tools/gateway.ts | 0 .../[automationId]/tools/prompt-for-info.ts | 10 + .../[automationId]/tools/prompt-for-secret.ts | 9 + .../[automationId]/tools/store-to-s3.ts | 11 + .../tools/task-automation-tools.ts | 4 +- .../tasks/[taskId]/automation/chat.tsx | 297 --------- .../[taskId]/automation/lib/chat-context.tsx | 51 -- .../tasks/[taskId]/automation/page.tsx | 33 - .../[taskId]/automation/tools/exa-search.ts | 144 ---- .../[taskId]/automation/tools/firecrawl.ts | 169 ----- .../tools/generate-files/deferred.ts | 24 - .../tools/generate-files/get-contents.ts | 93 --- .../automation/tools/prompt-for-info.ts | 45 -- .../automation/tools/prompt-for-secret.ts | 39 -- .../[taskId]/automation/tools/store-to-s3.ts | 134 ---- .../components/AutomationOverview.tsx | 187 ++++++ .../overview/components/MetricsSection.tsx | 145 ++++ .../overview/components/VersionsCard.tsx | 155 +++++ .../[automationId]/overview/page.tsx | 97 +++ .../components/AutomationRunsCard.tsx | 262 ++++++++ .../tasks/[taskId]/components/SingleTask.tsx | 99 ++- .../[taskId]/components/TaskAutomations.tsx | 132 ++++ .../tasks/[taskId]/components/TaskBody.tsx | 4 +- .../components/TaskPropertiesSidebar.tsx | 56 +- .../hooks/use-task-automation-runs.ts | 64 ++ .../[taskId]/hooks/use-task-automations.ts | 62 ++ .../[orgId]/tasks/[taskId]/hooks/use-task.ts | 58 ++ .../app/(app)/[orgId]/tasks/[taskId]/page.tsx | 48 +- .../(app)/[orgId]/tasks/actions/updateTask.ts | 1 + apps/app/src/app/api/auth/test-login/route.ts | 35 - apps/app/src/app/api/secrets/route.ts | 2 +- .../app/src/hooks/use-organization-members.ts | 59 ++ apps/app/src/lib/api-client.ts | 21 +- bun.lock | 92 ++- packages/db/package.json | 2 +- .../migration.sql | 57 ++ .../migration.sql | 2 + .../migration.sql | 25 + .../migration.sql | 5 + .../db/prisma/schema/automation-run.prisma | 46 ++ .../prisma/schema/automation-version.prisma | 19 + packages/db/prisma/schema/automation.prisma | 17 + packages/db/prisma/schema/task.prisma | 21 +- packages/docs/openapi.json | 628 ++++++++++++++++++ 159 files changed, 5225 insertions(+), 1332 deletions(-) create mode 100644 ENTERPRISE_API_AUTOMATION_VERSIONING.md create mode 100644 apps/api/src/automation/automation.controller.ts create mode 100644 apps/api/src/automation/automation.module.ts create mode 100644 apps/api/src/automation/automation.service.ts create mode 100644 apps/api/src/automation/dto/automation-error-responses.dto.ts create mode 100644 apps/api/src/automation/dto/automation-responses.dto.ts create mode 100644 apps/api/src/automation/dto/create-automation.dto.ts create mode 100644 apps/api/src/automation/dto/update-automation.dto.ts create mode 100644 apps/api/src/automation/schemas/automation-operations.ts create mode 100644 apps/api/src/automation/schemas/create-automation.responses.ts create mode 100644 apps/api/src/automation/schemas/update-automation.responses.ts create mode 100644 apps/api/src/tasks/automations/automations.controller.ts create mode 100644 apps/api/src/tasks/automations/automations.module.ts create mode 100644 apps/api/src/tasks/automations/automations.service.ts create mode 100644 apps/api/src/tasks/automations/dto/automation-error-responses.dto.ts create mode 100644 apps/api/src/tasks/automations/dto/automation-responses.dto.ts create mode 100644 apps/api/src/tasks/automations/dto/create-automation.dto.ts create mode 100644 apps/api/src/tasks/automations/dto/update-automation.dto.ts create mode 100644 apps/api/src/tasks/automations/schemas/automation-operations.ts create mode 100644 apps/api/src/tasks/automations/schemas/create-automation.responses.ts create mode 100644 apps/api/src/tasks/automations/schemas/update-automation.responses.ts rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/actions/task-automation-actions.ts (65%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/automation-layout-wrapper.tsx (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/AutomationPageClient.tsx (81%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationSettingsDialogs.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/PublishDialog.tsx rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ai-elements/conversation.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ai-elements/loader.tsx (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/chat/ChatBreadcrumb.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/chat/EmptyState.tsx rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/index.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/prompt-info.tsx (92%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/prompt-secret.tsx (93%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/reasoning.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/report-errors.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/research-activity.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/spinner.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-part/text.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message-spinner.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/message.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/tool-header.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/tool-message.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/chat/types.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/icons/github.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/icons/vercel-dashed.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/layout/panels.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/layout/sizing.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/markdown-renderer/markdown-renderer.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/model-selector/model-selector.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/model-selector/use-available-models.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/panels/panels.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/preview/preview.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/tabs/index.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/tabs/tab-content.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/tabs/tab-item.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/tabs/use-tab-state.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/badge.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/button.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/checkbox.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/dialog.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/input.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/label.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/popover.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/scroll-area.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/select.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/sonner.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/ui/textarea.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/CodeViewer.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/ConfettiEffect.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/EmptyState.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/TestDialog.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/TestResultsPanel.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/UnifiedWorkflowCard.tsx (97%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/ViewModeSwitch.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/WorkflowSkeleton.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/WorkflowStepCard.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/components/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/types.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/workflow-loading.tsx (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/components/workflow/workflow-visualizer-simple.tsx (56%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/constants/automation-examples.ts rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/hooks/index.ts (66%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-automation-versions.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-chat-handlers.ts rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{hooks/use-task-automation-workflow.ts => [automationId]/hooks/use-task-automation-analyze.ts} (79%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/hooks/use-task-automation-execution.ts (93%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/hooks/use-task-automation-script.ts (93%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/layout.tsx (71%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/lib/chat-context.tsx rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/deferred.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/is-relative-url.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/task-automation-api.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/task-automation-store.ts (94%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/types/data-parts.ts (100%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/types/index.ts (88%) rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/lib/types/metadata.ts (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/tools/exa-search.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/tools/firecrawl.ts rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/tools/gateway.ts (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/tools/prompt-for-info.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/tools/prompt-for-secret.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/tools/store-to-s3.ts rename apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/{ => [automationId]}/tools/task-automation-tools.ts (92%) delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/chat.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/lib/chat-context.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/page.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/tools/exa-search.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/tools/firecrawl.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/tools/generate-files/deferred.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/tools/generate-files/get-contents.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/tools/prompt-for-info.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/tools/prompt-for-secret.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/tools/store-to-s3.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/VersionsCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/AutomationRunsCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskAutomations.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-automation-runs.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-automations.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task.ts create mode 100644 apps/app/src/hooks/use-organization-members.ts create mode 100644 packages/db/prisma/migrations/20251008175411_add_automations_tables/migration.sql create mode 100644 packages/db/prisma/migrations/20251009193556_add_chat_history/migration.sql create mode 100644 packages/db/prisma/migrations/20251010162752_add_automation_versions/migration.sql create mode 100644 packages/db/prisma/migrations/20251010174252_add_version_to_automation_runs/migration.sql create mode 100644 packages/db/prisma/schema/automation-run.prisma create mode 100644 packages/db/prisma/schema/automation-version.prisma create mode 100644 packages/db/prisma/schema/automation.prisma diff --git a/.cursor/rules/design-system.mdc b/.cursor/rules/design-system.mdc index c754e1479..9a0d82de5 100644 --- a/.cursor/rules/design-system.mdc +++ b/.cursor/rules/design-system.mdc @@ -1,6 +1,5 @@ --- -description: -globs: *.tsx +description: Any time we are going to write react components and / or layouts alwaysApply: false --- @@ -28,10 +27,77 @@ Design System & Component Guidelines ## Layout & Spacing +- **Flexbox-First**: ALWAYS prefer flexbox with `gap` over hardcoded margins (`mt-`, `mb-`, `ml-`, `mr-`) +- **Use Gaps, Not Margins**: Use `gap-2`, `gap-4`, `space-y-4` for spacing between elements - **Consistent Spacing**: Use standard Tailwind spacing scale (`space-y-4`, `gap-6`, etc.) - **Card-Based Layouts**: Prefer Card components for content organization - **Minimal Padding**: Use conservative padding - `p-3`, `p-4` rather than larger values - **Clean Separators**: Use subtle borders (`border-t`, `border-muted`) instead of heavy dividers +- **NEVER Hardcode Margins**: Avoid `mt-4`, `mb-2`, `ml-3` unless absolutely necessary for exceptions + +## Color & Visual Elements + +- **Status Colors**: + - Green for completed/success states + - Blue for in-progress/info states + - Yellow for warnings + - Red for errors/destructive actions +- **Subtle Indicators**: Use small colored dots (`w-2 h-2 rounded-full`) instead of large icons for status +- **Minimal Shadows**: Prefer `hover:shadow-sm` over heavy shadow effects +- **Progress Bars**: Keep thin (`h-1`, `h-2`) for minimal visual weight + +## Interactive Elements + +- **Subtle Hover States**: Use gentle transitions (`transition-shadow`, `hover:shadow-sm`) +- **Consistent Button Sizing**: Prefer `size="sm"` for most buttons, `size="icon"` for icon-only +- **Badge Usage**: Keep badges minimal with essential info only (percentages, short status) + +## Data Display + +- **Shared Design Language**: Ensure related components (cards, overviews, details) use consistent patterns +- **Minimal Stats**: Present data cleanly without excessive decoration +- **Contextual Icons**: Use small, relevant icons (`h-3 w-3`, `h-4 w-4`) sparingly for context + +## Anti-Patterns to Avoid + +- Large text sizes (`text-2xl+` except for main headings) +- Heavy shadows or borders +- Excessive use of colored backgrounds +- Redundant badges or status indicators +- Complex custom styling overrides +- Non-semantic color usage (hardcoded hex values) +- Cluttered layouts with too many visual elements + Rule Name: design-system + Description: + Design System & Component Guidelines + +## Design Philosophy + +- **B2B, Modern, Flat, Minimal, Elegant**: All UI should follow a clean, professional aesthetic suitable for business applications +- **Sleek & Minimal**: Avoid visual clutter, use whitespace effectively, keep interfaces clean +- **Dark Mode First**: Always ensure components work seamlessly in both light and dark modes + +## Component Usage + +- **Adhere to Base Components**: Minimize custom overrides and stick to shadcn/ui base components whenever possible +- **Semantic Color Classes**: Use semantic classes like `text-muted-foreground`, `bg-muted/50` instead of hardcoded colors +- **Dark Mode Support**: Always use dark mode variants like `bg-green-50 dark:bg-green-950/20`, `text-green-600 dark:text-green-400` + +## Typography & Sizing + +- **Moderate Text Sizes**: Avoid overly large text - prefer `text-base`, `text-sm`, `text-xs` over `text-xl+` +- **Consistent Hierarchy**: Use `font-medium`, `font-semibold` sparingly, prefer `font-normal` with size differentiation +- **Tabular Numbers**: Use `tabular-nums` class for numeric data to ensure proper alignment + +## Layout & Spacing + +- **Flexbox-First**: ALWAYS prefer flexbox with `gap` over hardcoded margins (`mt-`, `mb-`, `ml-`, `mr-`) +- **Use Gaps, Not Margins**: Use `gap-2`, `gap-4`, `space-y-4` for spacing between elements +- **Consistent Spacing**: Use standard Tailwind spacing scale (`space-y-4`, `gap-6`, etc.) +- **Card-Based Layouts**: Prefer Card components for content organization +- **Minimal Padding**: Use conservative padding - `p-3`, `p-4` rather than larger values +- **Clean Separators**: Use subtle borders (`border-t`, `border-muted`) instead of heavy dividers +- **NEVER Hardcode Margins**: Avoid `mt-4`, `mb-2`, `ml-3` unless absolutely necessary for exceptions ## Color & Visual Elements diff --git a/ENTERPRISE_API_AUTOMATION_VERSIONING.md b/ENTERPRISE_API_AUTOMATION_VERSIONING.md new file mode 100644 index 000000000..4b0a56c01 --- /dev/null +++ b/ENTERPRISE_API_AUTOMATION_VERSIONING.md @@ -0,0 +1,184 @@ +# Enterprise API - Automation Versioning Endpoints + +## Overview + +Implement versioning for automation scripts. The Next.js app handles database operations (storing version metadata), while the Enterprise API handles S3 operations (copying/managing script files) and Redis operations (chat history). + +## Context + +### Current S3 Structure + +- **Draft script**: `{orgId}/{taskId}/{automationId}.automation.js` +- Scripts are stored in S3 via the enterprise API + +### New S3 Structure for Versions + +- **Draft script**: `{orgId}/{taskId}/{automationId}.draft.js` +- **Published versions**: `{orgId}/{taskId}/{automationId}.v{version}.js` + +**Migration Note**: Existing scripts at `{automationId}.automation.js` should be moved to `{automationId}.draft.js` + +### Database (handled by Next.js app) + +- `EvidenceAutomationVersion` table stores version metadata +- Next.js app creates version records after enterprise API copies files + +## Endpoints to Implement + +### 1. Publish Draft Script + +**Endpoint**: `POST /api/tasks-automations/publish` + +**Purpose**: Create a new version by copying current draft script to a versioned S3 key. + +**Request Body**: + +```typescript +{ + orgId: string; + taskId: string; + automationId: string; +} +``` + +**Process**: + +1. Construct draft S3 key: `{orgId}/{taskId}/{automationId}.draft.js` +2. Check if draft script exists in S3 +3. If not found, return error: `{ success: false, error: 'No draft script found to publish' }` +4. Query database to get the next version number: + - Find highest existing version for this `automationId` + - Increment by 1 (or start at 1 if no versions exist) +5. Construct version S3 key: `{orgId}/{taskId}/{automationId}.v{nextVersion}.js` +6. Copy draft script to version key in S3 +7. Return success with the version number and scriptKey + +**Response**: + +```typescript +{ + success: boolean; + version?: number; // e.g., 1, 2, 3 + scriptKey?: string; // e.g., "org_xxx/tsk_xxx/aut_xxx.v1.js" + error?: string; +} +``` + +**Note**: Enterprise API determines the version number server-side by querying the database, not from client input. This prevents version conflicts. + +**Error Cases**: + +- Draft script not found in S3 +- S3 copy operation fails +- Invalid orgId/taskId/automationId + +--- + +### 2. Restore Version to Draft + +**Endpoint**: `POST /api/tasks-automations/restore-version` + +**Purpose**: Replace current draft script with a published version's script. Chat history is preserved. + +**Request Body**: + +```typescript +{ + orgId: string; + taskId: string; + automationId: string; + version: number; // Which version to restore (e.g., 1, 2, 3) +} +``` + +**Process**: + +1. Construct version S3 key: `{orgId}/{taskId}/{automationId}.v{version}.js` +2. Check if version script exists in S3 +3. If not found, return error: `{ success: false, error: 'Version not found' }` +4. Construct draft S3 key: `{orgId}/{taskId}/{automationId}.draft.js` +5. Copy version script to draft key in S3 (overwrites current draft) +6. Do NOT touch Redis chat history - it should persist +7. Return success + +**Response**: + +```typescript +{ + success: boolean; + error?: string; +} +``` + +**Error Cases**: + +- Version script not found in S3 +- S3 copy operation fails +- Invalid version number + +--- + +## Implementation Notes + +### S3 Operations + +- Use AWS S3 SDK's `copyObject` method to copy between keys +- Bucket name should come from environment variables +- Ensure proper error handling for S3 operations + +### Authentication + +- These endpoints should require authentication (API key or session) +- Validate that the user has access to the organization/task/automation + +### Redis Chat History + +- **Important**: Do NOT clear or modify chat history when restoring versions +- Chat history key format: `automation:{automationId}:chat` +- Chat history persists regardless of which version is in the draft + +### Example S3 Keys + +For automation `aut_68e6a70803cf925eac17896a` in task `tsk_68e6a5c1e0b762e741c2e020`: + +- **Draft**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.draft.js` +- **Version 1**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.v1.js` +- **Version 2**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.v2.js` + +### Integration Flow + +#### Publishing a Version + +1. User clicks "Publish" in Next.js UI with optional changelog +2. Next.js calls `POST /api/tasks-automations/publish` (no version number in request) +3. Enterprise API: + - Queries database to get next version number + - Copies draft → versioned S3 key + - Returns version number and scriptKey +4. Next.js saves version record to database with returned version number, scriptKey, and changelog + +#### Restoring a Version + +1. User clicks "Restore Version X" in Next.js UI +2. Shows confirmation dialog warning current draft will be lost +3. Next.js calls `POST /api/tasks-automations/restore-version` +4. Enterprise API copies version script → draft S3 key +5. Enterprise API returns success +6. Next.js shows success message +7. User can continue editing in builder with restored script + +### Error Handling + +- Return proper HTTP status codes (404 for not found, 400 for bad request, 500 for S3 errors) +- Include descriptive error messages in response body +- Log errors for debugging + +### Testing Checklist + +- [ ] Can publish a draft script as version 1 +- [ ] Can publish multiple versions (1, 2, 3...) +- [ ] Cannot publish if no draft exists +- [ ] Can restore version 1 to draft +- [ ] Restoring doesn't affect chat history +- [ ] S3 keys follow correct naming convention +- [ ] Proper error messages when scripts don't exist diff --git a/apps/api/package.json b/apps/api/package.json index d46f9e4c9..1f905ed8e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@trycompai/db": "^1.3.7", "archiver": "^7.0.1", "axios": "^1.12.2", + "better-auth": "^1.3.27", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "jose": "^6.0.12", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2a1a10725..7280e9c5a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,7 +17,6 @@ import { TasksModule } from './tasks/tasks.module'; import { VendorsModule } from './vendors/vendors.module'; import { ContextModule } from './context/context.module'; - @Module({ imports: [ ConfigModule.forRoot({ diff --git a/apps/api/src/automation/automation.controller.ts b/apps/api/src/automation/automation.controller.ts new file mode 100644 index 000000000..26975fc94 --- /dev/null +++ b/apps/api/src/automation/automation.controller.ts @@ -0,0 +1,59 @@ +import { + Body, + Controller, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { AutomationService } from './automation.service'; +import { CreateAutomationDto } from './dto/create-automation.dto'; +import { UpdateAutomationDto } from './dto/update-automation.dto'; +import { AUTOMATION_OPERATIONS } from './schemas/automation-operations'; +import { CREATE_AUTOMATION_RESPONSES } from './schemas/create-automation.responses'; +import { UPDATE_AUTOMATION_RESPONSES } from './schemas/update-automation.responses'; + +@ApiTags('Automations') +@Controller({ path: 'automations', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class AutomationController { + constructor(private readonly automationService: AutomationService) {} + + @Post() + @ApiOperation(AUTOMATION_OPERATIONS.createAutomation) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[201]) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[400]) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[401]) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[404]) + async createAutomation(@Body() createAutomationDto: CreateAutomationDto) { + return this.automationService.create(createAutomationDto); + } + + @Patch(':automationId') + @ApiOperation(AUTOMATION_OPERATIONS.updateAutomation) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[200]) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[400]) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[401]) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[404]) + async updateAutomation( + @Param('automationId') automationId: string, + @Body() updateAutomationDto: UpdateAutomationDto, + ) { + return this.automationService.update(automationId, updateAutomationDto); + } +} diff --git a/apps/api/src/automation/automation.module.ts b/apps/api/src/automation/automation.module.ts new file mode 100644 index 000000000..14d470d6d --- /dev/null +++ b/apps/api/src/automation/automation.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AutomationController } from './automation.controller'; +import { AutomationService } from './automation.service'; + +@Module({ + imports: [AuthModule], + controllers: [AutomationController], + providers: [AutomationService], +}) +export class AutomationModule {} diff --git a/apps/api/src/automation/automation.service.ts b/apps/api/src/automation/automation.service.ts new file mode 100644 index 000000000..e07ea953a --- /dev/null +++ b/apps/api/src/automation/automation.service.ts @@ -0,0 +1,64 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { CreateAutomationDto } from './dto/create-automation.dto'; +import { UpdateAutomationDto } from './dto/update-automation.dto'; + +@Injectable() +export class AutomationService { + async create(createAutomationDto: CreateAutomationDto) { + const { taskId } = createAutomationDto; + + const task = await db.task.findFirst({ + where: { + id: taskId, + }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + const automation = await db.evidenceAutomation.create({ + data: { + name: `${task.title} - AI Automation`, + taskId: taskId, + }, + }); + + return { + success: true, + automation: { + id: automation.id, + name: automation.name, + }, + }; + } + + async update(automationId: string, updateAutomationDto: UpdateAutomationDto) { + const existingAutomation = await db.evidenceAutomation.findFirst({ + where: { + id: automationId, + }, + }); + + if (!existingAutomation) { + throw new NotFoundException('Automation not found'); + } + + const automation = await db.evidenceAutomation.update({ + where: { + id: automationId, + }, + data: updateAutomationDto, + }); + + return { + success: true, + automation: { + id: automation.id, + name: automation.name, + description: automation.description, + }, + }; + } +} diff --git a/apps/api/src/automation/dto/automation-error-responses.dto.ts b/apps/api/src/automation/dto/automation-error-responses.dto.ts new file mode 100644 index 000000000..57c394af9 --- /dev/null +++ b/apps/api/src/automation/dto/automation-error-responses.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BadRequestResponseDto { + @ApiProperty({ + description: 'Error message', + example: 'Invalid task ID or organization ID', + }) + message: string; +} + +export class UnauthorizedResponseDto { + @ApiProperty({ + description: 'Error message', + example: 'Unauthorized', + }) + message: string; +} + +export class TaskNotFoundResponseDto { + @ApiProperty({ + description: 'Error message', + example: 'Task not found', + }) + message: string; +} diff --git a/apps/api/src/automation/dto/automation-responses.dto.ts b/apps/api/src/automation/dto/automation-responses.dto.ts new file mode 100644 index 000000000..8c5106ea1 --- /dev/null +++ b/apps/api/src/automation/dto/automation-responses.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AutomationResponseDto { + @ApiProperty({ + description: 'Automation ID', + example: 'auto_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Automation name', + example: 'Task Name - Evidence Collection', + }) + name: string; + + @ApiProperty({ + description: 'Task ID this automation belongs to', + example: 'tsk_abc123def456', + }) + taskId: string; + + @ApiProperty({ + description: 'Organization ID', + example: 'org_abc123def456', + }) + organizationId: string; + + @ApiProperty({ + description: 'Automation status', + example: 'active', + enum: ['active', 'inactive', 'draft'], + }) + status: string; + + @ApiProperty({ + description: 'Creation timestamp', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'Last update timestamp', + example: '2024-01-15T10:30:00Z', + }) + updatedAt: Date; +} + +export class CreateAutomationResponseDto { + @ApiProperty({ + description: 'Success status', + example: true, + }) + success: boolean; + + @ApiProperty({ + description: 'Created automation details', + type: () => AutomationResponseDto, + }) + automation: { + id: string; + name: string; + }; +} diff --git a/apps/api/src/automation/dto/create-automation.dto.ts b/apps/api/src/automation/dto/create-automation.dto.ts new file mode 100644 index 000000000..3a75723f3 --- /dev/null +++ b/apps/api/src/automation/dto/create-automation.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CreateAutomationDto { + @ApiProperty({ + description: 'Task ID', + example: 'tsk_abc123def456', + }) + @IsString() + @IsNotEmpty() + taskId: string; +} diff --git a/apps/api/src/automation/dto/update-automation.dto.ts b/apps/api/src/automation/dto/update-automation.dto.ts new file mode 100644 index 000000000..9890f98d3 --- /dev/null +++ b/apps/api/src/automation/dto/update-automation.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateAutomationDto { + @ApiProperty({ + description: 'Automation name', + example: 'GitHub Security Check - Evidence Collection', + required: false, + }) + @IsString() + @IsOptional() + name?: string; + + @ApiProperty({ + description: 'Automation description', + example: 'Collects evidence about GitHub repository security settings', + required: false, + }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/automation/schemas/automation-operations.ts b/apps/api/src/automation/schemas/automation-operations.ts new file mode 100644 index 000000000..b5d9056fb --- /dev/null +++ b/apps/api/src/automation/schemas/automation-operations.ts @@ -0,0 +1,11 @@ +export const AUTOMATION_OPERATIONS = { + createAutomation: { + summary: 'Create a new evidence automation', + description: + 'Create an automation for collecting evidence for a specific task', + }, + updateAutomation: { + summary: 'Update an existing automation', + description: 'Update the name or description of an existing automation', + }, +}; diff --git a/apps/api/src/automation/schemas/create-automation.responses.ts b/apps/api/src/automation/schemas/create-automation.responses.ts new file mode 100644 index 000000000..5b60ccd75 --- /dev/null +++ b/apps/api/src/automation/schemas/create-automation.responses.ts @@ -0,0 +1,71 @@ +export const CREATE_AUTOMATION_RESPONSES = { + 201: { + status: 201, + description: 'Automation created successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + automation: { + type: 'object', + properties: { + id: { type: 'string', example: 'auto_abc123def456' }, + name: { + type: 'string', + example: 'Task Name - Evidence Collection', + }, + }, + }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid task ID or organization ID', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Invalid task ID or organization ID', + }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Unauthorized' }, + }, + }, + }, + }, + }, + 404: { + status: 404, + description: 'Task not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Task not found' }, + }, + }, + }, + }, + }, +}; diff --git a/apps/api/src/automation/schemas/update-automation.responses.ts b/apps/api/src/automation/schemas/update-automation.responses.ts new file mode 100644 index 000000000..8a8a5ec71 --- /dev/null +++ b/apps/api/src/automation/schemas/update-automation.responses.ts @@ -0,0 +1,69 @@ +export const UPDATE_AUTOMATION_RESPONSES = { + 200: { + status: 200, + description: 'Automation updated successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + automation: { + type: 'object', + properties: { + id: { type: 'string', example: 'auto_abc123def456' }, + name: { type: 'string', example: 'Updated Automation Name' }, + description: { type: 'string', example: 'Updated description' }, + }, + }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid automation ID or data', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Invalid automation data', + }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Unauthorized' }, + }, + }, + }, + }, + }, + 404: { + status: 404, + description: 'Automation not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Automation not found' }, + }, + }, + }, + }, + }, +}; diff --git a/apps/api/src/tasks/automations/automations.controller.ts b/apps/api/src/tasks/automations/automations.controller.ts new file mode 100644 index 000000000..f22b08805 --- /dev/null +++ b/apps/api/src/tasks/automations/automations.controller.ts @@ -0,0 +1,233 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '../../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { TasksService } from '../tasks.service'; +import { AutomationsService } from './automations.service'; +import { CreateAutomationDto } from './dto/create-automation.dto'; +import { UpdateAutomationDto } from './dto/update-automation.dto'; +import { AUTOMATION_OPERATIONS } from './schemas/automation-operations'; +import { CREATE_AUTOMATION_RESPONSES } from './schemas/create-automation.responses'; +import { UPDATE_AUTOMATION_RESPONSES } from './schemas/update-automation.responses'; + +@ApiTags('Task Automations') +@Controller({ path: 'tasks/:taskId/automations', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class AutomationsController { + constructor( + private readonly automationsService: AutomationsService, + private readonly tasksService: TasksService, + ) {} + + @Get() + @ApiOperation({ + summary: 'Get all automations for a task', + description: 'Retrieve all automations for a specific task', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiResponse({ + status: 200, + description: 'Automations retrieved successfully', + }) + async getTaskAutomations( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + ) { + // Verify task access first + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + return this.automationsService.findByTaskId(taskId); + } + + @Get(':automationId') + @ApiOperation({ + summary: 'Get automation details', + description: 'Retrieve details for a specific automation', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiParam({ + name: 'automationId', + description: 'Unique automation identifier', + example: 'auto_abc123def456', + }) + @ApiResponse({ + status: 200, + description: 'Automation details retrieved successfully', + }) + async getAutomation( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Param('automationId') automationId: string, + ) { + // Verify task access first + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + return this.automationsService.findById(automationId); + } + + @Post() + @ApiOperation(AUTOMATION_OPERATIONS.createAutomation) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[201]) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[400]) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[401]) + @ApiResponse(CREATE_AUTOMATION_RESPONSES[404]) + async createAutomation( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Body() createAutomationDto: CreateAutomationDto, + ) { + // Verify task access first + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + return this.automationsService.create(organizationId, { taskId }); + } + + @Patch(':automationId') + @ApiOperation(AUTOMATION_OPERATIONS.updateAutomation) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiParam({ + name: 'automationId', + description: 'Unique automation identifier', + example: 'auto_abc123def456', + }) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[200]) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[400]) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[401]) + @ApiResponse(UPDATE_AUTOMATION_RESPONSES[404]) + async updateAutomation( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Param('automationId') automationId: string, + @Body() updateAutomationDto: UpdateAutomationDto, + ) { + // Verify task access first + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + return this.automationsService.update(automationId, updateAutomationDto); + } + + @Delete(':automationId') + @ApiOperation({ + summary: 'Delete an automation', + description: 'Delete a specific automation and all its associated data', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiParam({ + name: 'automationId', + description: 'Unique automation identifier', + example: 'auto_abc123def456', + }) + @ApiResponse({ + status: 200, + description: 'Automation deleted successfully', + }) + async deleteAutomation( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Param('automationId') automationId: string, + ) { + // Verify task access first + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + return this.automationsService.delete(automationId); + } + + @Get(':automationId/versions') + @ApiOperation({ + summary: 'Get all versions for an automation', + description: 'Retrieve all published versions of an automation script', + }) + @ApiParam({ + name: 'taskId', + description: 'Task ID', + }) + @ApiParam({ + name: 'automationId', + description: 'Automation ID', + }) + @ApiResponse({ + status: 200, + description: 'Versions retrieved successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + versions: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + version: { type: 'number' }, + scriptKey: { type: 'string' }, + changelog: { type: 'string', nullable: true }, + publishedBy: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }) + async getAutomationVersions( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Param('automationId') automationId: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + await this.tasksService.verifyTaskAccess(organizationId, taskId); + const parsedLimit = limit ? parseInt(limit) : undefined; + const parsedOffset = offset ? parseInt(offset) : undefined; + return this.automationsService.listVersions( + automationId, + parsedLimit, + parsedOffset, + ); + } +} diff --git a/apps/api/src/tasks/automations/automations.module.ts b/apps/api/src/tasks/automations/automations.module.ts new file mode 100644 index 000000000..27127d42f --- /dev/null +++ b/apps/api/src/tasks/automations/automations.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { TasksService } from '../tasks.service'; +import { AutomationsController } from './automations.controller'; +import { AutomationsService } from './automations.service'; + +@Module({ + imports: [AuthModule], + controllers: [AutomationsController], + providers: [AutomationsService, TasksService], + exports: [AutomationsService], +}) +export class AutomationsModule {} diff --git a/apps/api/src/tasks/automations/automations.service.ts b/apps/api/src/tasks/automations/automations.service.ts new file mode 100644 index 000000000..f7f5931b1 --- /dev/null +++ b/apps/api/src/tasks/automations/automations.service.ts @@ -0,0 +1,156 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { CreateAutomationDto } from './dto/create-automation.dto'; +import { UpdateAutomationDto } from './dto/update-automation.dto'; + +@Injectable() +export class AutomationsService { + async findByTaskId(taskId: string) { + const automations = await db.evidenceAutomation.findMany({ + where: { + taskId: taskId, + }, + include: { + runs: { + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + return { + success: true, + automations, + }; + } + + async findById(automationId: string) { + const automation = await db.evidenceAutomation.findFirst({ + where: { + id: automationId, + }, + }); + + if (!automation) { + throw new NotFoundException('Automation not found'); + } + + return { + success: true, + automation, + }; + } + + async create( + organizationId: string, + createAutomationDto: CreateAutomationDto, + ) { + const { taskId } = createAutomationDto; + + // Verify task exists and belongs to organization + const task = await db.task.findFirst({ + where: { + id: taskId, + organizationId: organizationId, + }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + // Create the automation + const automation = await db.evidenceAutomation.create({ + data: { + name: `${task.title} - Evidence Collection`, + taskId: taskId, + }, + }); + + return { + success: true, + automation: { + id: automation.id, + name: automation.name, + }, + }; + } + + async update(automationId: string, updateAutomationDto: UpdateAutomationDto) { + // Verify automation exists and belongs to organization + const existingAutomation = await db.evidenceAutomation.findFirst({ + where: { + id: automationId, + }, + }); + + if (!existingAutomation) { + throw new NotFoundException('Automation not found'); + } + + // Update the automation + const automation = await db.evidenceAutomation.update({ + where: { + id: automationId, + }, + data: updateAutomationDto, + }); + + return { + success: true, + automation: { + id: automation.id, + name: automation.name, + description: automation.description, + }, + }; + } + + async delete(automationId: string) { + // Verify automation exists and belongs to organization + const existingAutomation = await db.evidenceAutomation.findFirst({ + where: { + id: automationId, + }, + }); + + if (!existingAutomation) { + throw new NotFoundException('Automation not found'); + } + + // Delete the automation + await db.evidenceAutomation.delete({ + where: { + id: automationId, + }, + }); + + return { + success: true, + message: 'Automation deleted successfully', + }; + } + + async listVersions(automationId: string, limit?: number, offset?: number) { + const versions = await db.evidenceAutomationVersion.findMany({ + where: { + evidenceAutomationId: automationId, + }, + orderBy: { + version: 'desc', + }, + ...(limit && { take: limit }), + ...(offset && { skip: offset }), + }); + + return { + success: true, + versions, + }; + } +} diff --git a/apps/api/src/tasks/automations/dto/automation-error-responses.dto.ts b/apps/api/src/tasks/automations/dto/automation-error-responses.dto.ts new file mode 100644 index 000000000..57c394af9 --- /dev/null +++ b/apps/api/src/tasks/automations/dto/automation-error-responses.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BadRequestResponseDto { + @ApiProperty({ + description: 'Error message', + example: 'Invalid task ID or organization ID', + }) + message: string; +} + +export class UnauthorizedResponseDto { + @ApiProperty({ + description: 'Error message', + example: 'Unauthorized', + }) + message: string; +} + +export class TaskNotFoundResponseDto { + @ApiProperty({ + description: 'Error message', + example: 'Task not found', + }) + message: string; +} diff --git a/apps/api/src/tasks/automations/dto/automation-responses.dto.ts b/apps/api/src/tasks/automations/dto/automation-responses.dto.ts new file mode 100644 index 000000000..8c5106ea1 --- /dev/null +++ b/apps/api/src/tasks/automations/dto/automation-responses.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AutomationResponseDto { + @ApiProperty({ + description: 'Automation ID', + example: 'auto_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Automation name', + example: 'Task Name - Evidence Collection', + }) + name: string; + + @ApiProperty({ + description: 'Task ID this automation belongs to', + example: 'tsk_abc123def456', + }) + taskId: string; + + @ApiProperty({ + description: 'Organization ID', + example: 'org_abc123def456', + }) + organizationId: string; + + @ApiProperty({ + description: 'Automation status', + example: 'active', + enum: ['active', 'inactive', 'draft'], + }) + status: string; + + @ApiProperty({ + description: 'Creation timestamp', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'Last update timestamp', + example: '2024-01-15T10:30:00Z', + }) + updatedAt: Date; +} + +export class CreateAutomationResponseDto { + @ApiProperty({ + description: 'Success status', + example: true, + }) + success: boolean; + + @ApiProperty({ + description: 'Created automation details', + type: () => AutomationResponseDto, + }) + automation: { + id: string; + name: string; + }; +} diff --git a/apps/api/src/tasks/automations/dto/create-automation.dto.ts b/apps/api/src/tasks/automations/dto/create-automation.dto.ts new file mode 100644 index 000000000..3a75723f3 --- /dev/null +++ b/apps/api/src/tasks/automations/dto/create-automation.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CreateAutomationDto { + @ApiProperty({ + description: 'Task ID', + example: 'tsk_abc123def456', + }) + @IsString() + @IsNotEmpty() + taskId: string; +} diff --git a/apps/api/src/tasks/automations/dto/update-automation.dto.ts b/apps/api/src/tasks/automations/dto/update-automation.dto.ts new file mode 100644 index 000000000..9890f98d3 --- /dev/null +++ b/apps/api/src/tasks/automations/dto/update-automation.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateAutomationDto { + @ApiProperty({ + description: 'Automation name', + example: 'GitHub Security Check - Evidence Collection', + required: false, + }) + @IsString() + @IsOptional() + name?: string; + + @ApiProperty({ + description: 'Automation description', + example: 'Collects evidence about GitHub repository security settings', + required: false, + }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/tasks/automations/schemas/automation-operations.ts b/apps/api/src/tasks/automations/schemas/automation-operations.ts new file mode 100644 index 000000000..b5d9056fb --- /dev/null +++ b/apps/api/src/tasks/automations/schemas/automation-operations.ts @@ -0,0 +1,11 @@ +export const AUTOMATION_OPERATIONS = { + createAutomation: { + summary: 'Create a new evidence automation', + description: + 'Create an automation for collecting evidence for a specific task', + }, + updateAutomation: { + summary: 'Update an existing automation', + description: 'Update the name or description of an existing automation', + }, +}; diff --git a/apps/api/src/tasks/automations/schemas/create-automation.responses.ts b/apps/api/src/tasks/automations/schemas/create-automation.responses.ts new file mode 100644 index 000000000..5b60ccd75 --- /dev/null +++ b/apps/api/src/tasks/automations/schemas/create-automation.responses.ts @@ -0,0 +1,71 @@ +export const CREATE_AUTOMATION_RESPONSES = { + 201: { + status: 201, + description: 'Automation created successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + automation: { + type: 'object', + properties: { + id: { type: 'string', example: 'auto_abc123def456' }, + name: { + type: 'string', + example: 'Task Name - Evidence Collection', + }, + }, + }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid task ID or organization ID', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Invalid task ID or organization ID', + }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Unauthorized' }, + }, + }, + }, + }, + }, + 404: { + status: 404, + description: 'Task not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Task not found' }, + }, + }, + }, + }, + }, +}; diff --git a/apps/api/src/tasks/automations/schemas/update-automation.responses.ts b/apps/api/src/tasks/automations/schemas/update-automation.responses.ts new file mode 100644 index 000000000..8a8a5ec71 --- /dev/null +++ b/apps/api/src/tasks/automations/schemas/update-automation.responses.ts @@ -0,0 +1,69 @@ +export const UPDATE_AUTOMATION_RESPONSES = { + 200: { + status: 200, + description: 'Automation updated successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + automation: { + type: 'object', + properties: { + id: { type: 'string', example: 'auto_abc123def456' }, + name: { type: 'string', example: 'Updated Automation Name' }, + description: { type: 'string', example: 'Updated description' }, + }, + }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid automation ID or data', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Invalid automation data', + }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Unauthorized' }, + }, + }, + }, + }, + }, + 404: { + status: 404, + description: 'Automation not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Automation not found' }, + }, + }, + }, + }, + }, +}; diff --git a/apps/api/src/tasks/dto/task-responses.dto.ts b/apps/api/src/tasks/dto/task-responses.dto.ts index 442ded044..28a240d96 100644 --- a/apps/api/src/tasks/dto/task-responses.dto.ts +++ b/apps/api/src/tasks/dto/task-responses.dto.ts @@ -1,3 +1,4 @@ +import { MemberResponseDto } from '@/devices/dto/member-responses.dto'; import { ApiProperty } from '@nestjs/swagger'; export class AttachmentResponseDto { @@ -38,8 +39,6 @@ export class AttachmentResponseDto { createdAt: Date; } - - export class TaskResponseDto { @ApiProperty({ description: 'Unique identifier for the task', diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index d6faee26a..7632a0b7f 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -8,6 +8,7 @@ import { HttpCode, HttpStatus, Param, + Patch, Post, UseGuards, } from '@nestjs/common'; @@ -496,4 +497,66 @@ export class TasksController { message: 'Attachment deleted successfully', }; } + + // ==================== AUTOMATION RUNS ==================== + + @Get(':taskId/automation-runs') + @ApiOperation({ + summary: 'Get all automation runs for a task', + description: 'Retrieve all evidence automation runs for a specific task', + }) + @ApiParam({ + name: 'taskId', + description: 'Task ID', + example: 'tsk_abc123def456', + }) + @ApiResponse({ + status: 200, + description: 'Automation runs retrieved successfully', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'ear_abc123def456' }, + status: { + type: 'string', + enum: ['PENDING', 'RUNNING', 'COMPLETED', 'FAILED'], + }, + trigger: { + type: 'string', + enum: ['MANUAL', 'SCHEDULED', 'EVENT'], + }, + createdAt: { type: 'string', format: 'date-time' }, + completedAt: { + type: 'string', + format: 'date-time', + nullable: true, + }, + error: { type: 'object', nullable: true }, + }, + }, + }, + }, + }, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized', + }) + @ApiResponse({ + status: 404, + description: 'Task not found', + }) + async getTaskAutomationRuns( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + ) { + return await this.tasksService.getTaskAutomationRuns( + organizationId, + taskId, + ); + } } diff --git a/apps/api/src/tasks/tasks.module.ts b/apps/api/src/tasks/tasks.module.ts index 34a6604a8..914cb2612 100644 --- a/apps/api/src/tasks/tasks.module.ts +++ b/apps/api/src/tasks/tasks.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AttachmentsModule } from '../attachments/attachments.module'; import { AuthModule } from '../auth/auth.module'; +import { AutomationsModule } from './automations/automations.module'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; @Module({ - imports: [AuthModule, AttachmentsModule], + imports: [AuthModule, AttachmentsModule, AutomationsModule], controllers: [TasksController], providers: [TasksService], exports: [TasksService], diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index e64d76264..375c64e74 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -49,20 +49,16 @@ export class TasksService { id: taskId, organizationId, }, + include: { + assignee: true, + }, }); if (!task) { throw new BadRequestException('Task not found or access denied'); } - return { - id: task.id, - title: task.title, - description: task.description, - status: task.status, - createdAt: task.createdAt, - updatedAt: task.updatedAt, - }; + return task; } catch (error) { console.error('Error fetching task:', error); if (error instanceof BadRequestException) { @@ -90,4 +86,30 @@ export class TasksService { throw new BadRequestException('Task not found or access denied'); } } + + /** + * Get all automation runs for a task + */ + async getTaskAutomationRuns(organizationId: string, taskId: string) { + // Verify task access + await this.verifyTaskAccess(organizationId, taskId); + + const runs = await db.evidenceAutomationRun.findMany({ + where: { + taskId, + }, + include: { + evidenceAutomation: { + select: { + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return runs; + } } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7bc839792..7e903ec25 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -10,7 +10,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "ES2023", + "target": "esnext", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 254702356..affee2e36 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -39,7 +39,7 @@ const config: NextConfig = { process.env.NODE_ENV === 'production' && process.env.STATIC_ASSETS_URL ? `${process.env.STATIC_ASSETS_URL}/app` : '', - reactStrictMode: true, + reactStrictMode: false, transpilePackages: ['@trycompai/db', '@prisma/client'], images: { remotePatterns: [ diff --git a/apps/app/package.json b/apps/app/package.json index 9f6548972..a1c39c34d 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -6,7 +6,7 @@ "@ai-sdk/groq": "^2.0.0", "@ai-sdk/openai": "^2.0.0", "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/react": "^2.0.0", + "@ai-sdk/react": "^2.0.60", "@ai-sdk/rsc": "^1.0.0", "@aws-sdk/client-lambda": "^3.891.0", "@aws-sdk/client-s3": "^3.859.0", @@ -59,12 +59,13 @@ "@upstash/ratelimit": "^2.0.5", "@vercel/sandbox": "^0.0.21", "@vercel/sdk": "^1.7.1", - "ai": "^5.0.0", + "ai": "^5.0.60", "axios": "^1.9.0", "better-auth": "^1.3.27", "botid": "^1.5.5", "canvas-confetti": "^1.9.3", "d3": "^7.9.0", + "date-fns": "^4.1.0", "dub": "^0.66.1", "framer-motion": "^12.18.1", "geist": "^1.3.1", diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx index bb32ec3cb..d26062890 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx @@ -22,6 +22,10 @@ export default async function DashboardPage({ params }: { params: Promise<{ orgI headers: await headers(), }); + if (!session) { + redirect('/login'); + } + const org = await db.organization.findUnique({ where: { id: organizationId }, select: { onboardingCompleted: true }, diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index c9c9fcacd..e07fbd922 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -24,19 +24,17 @@ export default async function EmployeeDetailsPage({ headers: await headers(), }); - const organizationId = session?.session.activeOrganizationId; - const currentUserMember = await db.member.findFirst({ where: { - organizationId, - userId: session?.user.id, + organizationId: orgId, + userId: session?.user?.id, }, }); const canEditMembers = currentUserMember?.role.includes('owner') || currentUserMember?.role.includes('admin') || false; - if (!organizationId) { + if (!orgId) { redirect('/'); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index 3bb957f10..fdeff7173 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -21,11 +21,15 @@ export async function TeamMembers() { const session = await auth.api.getSession({ headers: await headers(), }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session?.activeOrganizationId; + + if (!organizationId) { + return null; + } const currentUserMember = await db.member.findFirst({ where: { - organizationId: session?.session.activeOrganizationId, + organizationId: organizationId, userId: session?.user.id, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/actions/task-automation-actions.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/task-automation-actions.ts similarity index 65% rename from apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/actions/task-automation-actions.ts rename to apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/task-automation-actions.ts index 7af234d9c..4b7d4955a 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/actions/task-automation-actions.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/task-automation-actions.ts @@ -1,5 +1,6 @@ 'use server'; +import { db } from '@db'; /** * Server actions for task automation * These actions securely call the enterprise API with server-side license key @@ -60,6 +61,8 @@ async function callEnterpriseApi( }); } + console.log('url', url.toString()); + const method = options.method || 'GET'; const response = await fetch(url.toString(), { @@ -182,7 +185,7 @@ export async function listAutomationScripts(orgId: string) { export async function executeAutomationScript(data: { orgId: string; taskId: string; - sandboxId?: string; + automationId: string; }) { try { const result = await callEnterpriseApi('/api/tasks-automations/trigger/execute', { @@ -263,3 +266,149 @@ export const getAutomationRunStatus = async (runId: string) => { }; } }; + +/** + * Load chat history for an automation + */ +export async function loadChatHistory(automationId: string, offset = 0, limit = 50) { + try { + const response = await callEnterpriseApi<{ + messages: any[]; + total: number; + hasMore: boolean; + }>('/api/tasks-automations/chat/history', { + method: 'GET', + params: { + automationId, + offset: offset.toString(), + limit: limit.toString(), + }, + }); + + return { + success: true, + data: response, + }; + } catch (error) { + console.error('[loadChatHistory] Failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load chat history', + }; + } +} + +/** + * Save chat history for an automation + */ +export async function saveChatHistory(automationId: string, messages: any[]) { + try { + await callEnterpriseApi('/api/tasks-automations/chat/save', { + method: 'POST', + body: { + automationId, + messages, + }, + }); + + return { + success: true, + }; + } catch (error) { + console.error('[saveChatHistory] Failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save chat history', + }; + } +} + +/** + * Publish current draft as a new version + */ +export async function publishAutomation( + orgId: string, + taskId: string, + automationId: string, + changelog?: string, +) { + try { + // Call enterprise API to copy draft → versioned S3 key + const response = await callEnterpriseApi<{ + success: boolean; + version: number; + scriptKey: string; + }>('/api/tasks-automations/publish', { + method: 'POST', + body: { + orgId, + taskId, + automationId, + }, + }); + + if (!response.success) { + throw new Error('Enterprise API failed to publish'); + } + + // Save version record to database + const version = await db.evidenceAutomationVersion.create({ + data: { + evidenceAutomationId: automationId, + version: response.version, + scriptKey: response.scriptKey, + changelog, + }, + }); + + return { + success: true, + version, + }; + } catch (error) { + console.error('[publishAutomation] Failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to publish automation', + }; + } +} + +/** + * Restore a version to draft + */ +export async function restoreVersion( + orgId: string, + taskId: string, + automationId: string, + version: number, +) { + try { + const response = await callEnterpriseApi<{ success: boolean }>( + '/api/tasks-automations/restore-version', + { + method: 'POST', + body: { + orgId, + taskId, + automationId, + version, + }, + }, + ); + + if (!response.success) { + throw new Error('Enterprise API failed to restore version'); + } + + return { + success: true, + }; + } catch (error) { + console.error('[restoreVersion] Failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore version', + }; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/automation-layout-wrapper.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/automation-layout-wrapper.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/automation-layout-wrapper.tsx rename to apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/automation-layout-wrapper.tsx diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx new file mode 100644 index 000000000..42f804142 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { useChat } from '@ai-sdk/react'; +import Image from 'next/image'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from './components/ai-elements/conversation'; +import { ChatBreadcrumb } from './components/chat/ChatBreadcrumb'; +import { EmptyState } from './components/chat/EmptyState'; +import { Message } from './components/chat/message'; +import type { ChatUIMessage } from './components/chat/types'; +import { PanelHeader } from './components/panels/panels'; +import { Input } from './components/ui/input'; +import { useChatHandlers } from './hooks/use-chat-handlers'; +import { useTaskAutomation } from './hooks/use-task-automation'; +import { useSharedChatContext } from './lib/chat-context'; +import { useTaskAutomationStore } from './lib/task-automation-store'; + +interface Props { + className: string; + modelId?: string; + orgId: string; + taskId: string; + taskName?: string; + automationId: string; +} + +export function Chat({ className, orgId, taskId, taskName, automationId }: Props) { + const [input, setInput] = useState(''); + const { chat, updateAutomationId } = useSharedChatContext(); + const { messages, sendMessage, status } = useChat({ + chat, + }); + const { setChatStatus, scriptUrl } = useTaskAutomationStore(); + const inputRef = useRef(null); + const { automation } = useTaskAutomation(); + + // Ephemeral mode - automation not created yet + const isEphemeral = automationId === 'new'; + + const { validateAndSubmitMessage, handleSecretAdded, handleInfoProvided } = useChatHandlers({ + sendMessage, + setInput, + orgId, + taskId, + automationId, + isEphemeral, + updateAutomationId, + }); + + const handleExampleClick = useCallback( + (prompt: string) => { + setInput(prompt); + inputRef.current?.focus(); + }, + [setInput], + ); + + useEffect(() => { + setChatStatus(status); + }, [status, setChatStatus]); + + const hasMessages = messages.length > 0; + + return ( +
+ Automation + + +
+ +
+
+ + {/* Messages Area */} + {!hasMessages ? ( +
{ + event.preventDefault(); + validateAndSubmitMessage(input); + }} + > + + + ) : ( +
+ + + {messages.map((message) => ( + + ))} + + + + +
{ + event.preventDefault(); + validateAndSubmitMessage(input); + }} + > + setInput(e.target.value)} + placeholder="Ask me to create an automation..." + value={input} + /> +
+
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/components/AutomationPageClient.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationPageClient.tsx similarity index 81% rename from apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/components/AutomationPageClient.tsx rename to apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationPageClient.tsx index 4f32f535c..645c1647e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/components/AutomationPageClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationPageClient.tsx @@ -11,10 +11,11 @@ import { WorkflowVisualizerSimple as WorkflowVisualizer } from './workflow/workf interface Props { orgId: string; taskId: string; + automationId: string; taskName: string; } -export function AutomationPageClient({ orgId, taskId, taskName }: Props) { +export function AutomationPageClient({ orgId, taskId, automationId, taskName }: Props) { const { scriptUrl } = useTaskAutomationStore(); const { chat } = useSharedChatContext(); const { messages } = useChat({ chat }); @@ -31,7 +32,13 @@ export function AutomationPageClient({ orgId, taskId, taskName }: Props) { {/* Mobile layout tabs taking the whole space*/}
- + @@ -45,7 +52,13 @@ export function AutomationPageClient({ orgId, taskId, taskName }: Props) { scriptUrl || hasMessages ? 'w-1/2' : 'w-full' }`} > - +
{/* Workflow panel - slides in from right */} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationSettingsDialogs.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationSettingsDialogs.tsx new file mode 100644 index 000000000..f516f4c34 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationSettingsDialogs.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { Input } from '@comp/ui/input'; +import { Label } from '@comp/ui/label'; +import { Textarea } from '@comp/ui/textarea'; +import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { useTaskAutomation } from '../hooks/use-task-automation'; + +interface EditNameDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface EditDescriptionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface DeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditNameDialog({ open, onOpenChange }: EditNameDialogProps) { + const { automation, mutate } = useTaskAutomation(); + const { orgId, taskId, automationId } = useParams<{ + orgId: string; + taskId: string; + automationId: string; + }>(); + const [name, setName] = useState(automation?.name || ''); + const [isSaving, setIsSaving] = useState(false); + + // Update local state when automation data changes + useEffect(() => { + setName(automation?.name || ''); + }, [automation?.name]); + + const handleSave = async () => { + if (!name.trim()) { + toast.error('Name cannot be empty'); + return; + } + + setIsSaving(true); + try { + const response = await api.patch( + `/v1/tasks/${taskId}/automations/${automationId}`, + { name: name.trim() }, + orgId, + ); + + if (response.error) { + throw new Error(response.error); + } + + await mutate(); // Refresh automation data + onOpenChange(false); + toast.success('Automation name updated'); + } catch (error) { + toast.error('Failed to update name'); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + Edit Automation Name + + Update the name for this automation. This will help you identify it later. + + + +
+
+ + setName(e.target.value)} + placeholder="Enter automation name" + /> +
+
+ + + + + +
+
+ ); +} + +export function EditDescriptionDialog({ open, onOpenChange }: EditDescriptionDialogProps) { + const { automation, mutate } = useTaskAutomation(); + const { orgId, taskId, automationId } = useParams<{ + orgId: string; + taskId: string; + automationId: string; + }>(); + const [description, setDescription] = useState(automation?.description || ''); + const [isSaving, setIsSaving] = useState(false); + + // Update local state when automation data changes + useEffect(() => { + setDescription(automation?.description || ''); + }, [automation?.description]); + + const handleSave = async () => { + setIsSaving(true); + try { + const response = await api.patch( + `/v1/tasks/${taskId}/automations/${automationId}`, + { description: description.trim() }, + orgId, + ); + + if (response.error) { + throw new Error(response.error); + } + + await mutate(); // Refresh automation data + onOpenChange(false); + toast.success('Automation description updated'); + } catch (error) { + toast.error('Failed to update description'); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + Edit Automation Description + + Add or update the description for this automation to help others understand its purpose. + + + +
+
+ +