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/Dockerfile b/apps/api/Dockerfile index a66077e63..10d59af5b 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,23 +1,32 @@ # Use Node.js runtime for production FROM node:20-alpine -# Install wget for health check -RUN apk add --no-cache wget +# Install required packages (wget for healthcheck, curl/bash for bun install, libc compat for prisma) +RUN apk add --no-cache wget curl bash libc6-compat openssl WORKDIR /app -# Copy the entire pre-built bundle (no dependencies needed) +# Copy manifests first and install production deps inside the image (avoid broken bun symlinks) +COPY package.json ./ + +# Install bun and deps +RUN curl -fsSL https://bun.sh/install | bash \ + && export PATH="/root/.bun/bin:$PATH" \ + && bun install --production --ignore-scripts + +# Now copy the pre-built app contents (dist/, prisma/, etc.) COPY . . +# Generate Prisma client inside the image (ensures runtime client matches installed deps) +RUN npx prisma generate + # Set environment variables ENV NODE_ENV=production ENV PORT=3333 # Create a non-root user for security -RUN addgroup --system nestjs && adduser --system --ingroup nestjs nestjs - -# Change ownership to nestjs user -RUN chown -R nestjs:nestjs /app +RUN addgroup --system nestjs && adduser --system --ingroup nestjs nestjs \ + && chown -R nestjs:nestjs /app USER nestjs diff --git a/apps/api/buildspec.yml b/apps/api/buildspec.yml index e6a222549..d6809fe8a 100644 --- a/apps/api/buildspec.yml +++ b/apps/api/buildspec.yml @@ -29,7 +29,7 @@ phases: # Install only API workspace dependencies - echo "Installing API dependencies only..." - - bun install --filter=@comp/api --frozen-lockfile + - bun install --filter=@comp/api --frozen-lockfile || bun install --filter=@comp/api --ignore-scripts || bun install --ignore-scripts # Build NestJS application (prebuild automatically handles Prisma) - echo "Building NestJS application..." @@ -66,12 +66,14 @@ phases: - '[ -f "../docker-build/src/main.js" ] || { echo "❌ main.js not found in docker-build/src"; exit 1; }' # Copy entire node_modules for runtime (includes @trycompai/db from npm) - - echo "Bundling all runtime dependencies..." - - cp -r ../../node_modules ../docker-build/ + - echo "Skipping host node_modules copy; Dockerfile installs prod deps inside image" # Copy Dockerfile - echo "Copying Dockerfile..." - cp Dockerfile ../docker-build/ + - echo "Copying package manifests for image install..." + - cp package.json ../docker-build/ + - cp ../../bun.lock ../docker-build/ || true # Build Docker image - echo "Building Docker image..." diff --git a/apps/api/package.json b/apps/api/package.json index d46f9e4c9..75fead8ab 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,8 @@ "version": "0.0.1", "author": "", "dependencies": { + "@prisma/client": "^6.13.0", + "prisma": "^6.13.0", "@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/s3-request-presigner": "^3.859.0", "@nestjs/common": "^11.0.1", @@ -14,6 +16,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", @@ -70,8 +73,8 @@ "private": true, "scripts": { "build": "nest build", - "build:docker": "prisma generate && nest build", - "db:generate": "bun run db:getschema && prisma generate", + "build:docker": "bunx prisma generate && nest build", + "db:generate": "bun run db:getschema && bunx prisma generate", "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api", "dev": "nest start --watch", 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/tasks/automations/automations.controller.ts b/apps/api/src/tasks/automations/automations.controller.ts new file mode 100644 index 000000000..74311c8f4 --- /dev/null +++ b/apps/api/src/tasks/automations/automations.controller.ts @@ -0,0 +1,290 @@ +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, + ); + } + + // ==================== AUTOMATION RUNS (per task) ==================== + + @Get('runs') + @ApiOperation({ + summary: 'Get all automation runs for a task', + description: + 'Retrieve all evidence automation runs across automations 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 }, + }, + }, + }, + }, + }, + }) + async getTaskAutomationRuns( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + ) { + // Verify task access first + await this.tasksService.verifyTaskAccess(organizationId, taskId); + return await this.tasksService.getTaskAutomationRuns( + organizationId, + taskId, + ); + } +} 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..cde966a7d 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -5,21 +5,18 @@ import { Controller, Delete, Get, - HttpCode, - HttpStatus, Param, Post, UseGuards, } from '@nestjs/common'; import { + ApiExtraModels, ApiHeader, ApiOperation, ApiParam, ApiResponse, - ApiNoContentResponse, ApiSecurity, ApiTags, - ApiExtraModels, } from '@nestjs/swagger'; import { AttachmentsService } from '../attachments/attachments.service'; import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; 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 64% 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..fa89f92ef 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', { @@ -190,7 +193,7 @@ export async function executeAutomationScript(data: { body: data, }); - await revalidateCurrentPath(); + // Don't revalidate - causes page refresh. Test results are handled via polling/state. return { success: true, data: result }; } catch (error) { const typedError = error as EnterpriseApiError; @@ -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..bb7d5597a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/chat.tsx @@ -0,0 +1,153 @@ +'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, automationIdRef } = useSharedChatContext(); + const { messages, sendMessage, status } = useChat({ + chat, + }); + const { setChatStatus, scriptUrl } = useTaskAutomationStore(); + const inputRef = useRef(null); + const { automation } = useTaskAutomation(); + + // Update shared ref when automation is loaded from hook + if (automation?.id && automationIdRef.current === 'new') { + automationIdRef.current = automation.id; + } + + // Ephemeral mode - automation not created yet + // Check the shared ref, not the URL param + const isEphemeral = automationIdRef.current === 'new'; + + const { validateAndSubmitMessage, handleSecretAdded, handleInfoProvided } = useChatHandlers({ + sendMessage, + setInput, + orgId, + taskId, + automationId: automationIdRef.current, + 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..c05bdd91c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/AutomationSettingsDialogs.tsx @@ -0,0 +1,253 @@ +'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; + onSuccess?: () => void; +} + +interface EditDescriptionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +interface DeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function EditNameDialog({ open, onOpenChange, onSuccess }: EditNameDialogProps) { + const { automation, mutate: mutateLocal } = useTaskAutomation(); + const { orgId, taskId, automationId } = useParams<{ + orgId: string; + taskId: string; + automationId: string; + }>(); + + // Use real automation ID when available + const realAutomationId = automation?.id || automationId; + + 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/${realAutomationId}`, + { name: name.trim() }, + orgId, + ); + + if (response.error) { + throw new Error(response.error); + } + + await mutateLocal(); // Refresh automation data in hook + await onSuccess?.(); // Notify parent to refresh (e.g., overview page) + 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, + onSuccess, +}: EditDescriptionDialogProps) { + const { automation, mutate: mutateLocal } = 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 mutateLocal(); // Refresh automation data in hook + await onSuccess?.(); // Notify parent to refresh (e.g., overview page) + 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. + + + +
+
+ +