From 24fac17b2cede89f5071f453c774569f72301428 Mon Sep 17 00:00:00 2001 From: Mayank Kush Date: Wed, 3 Dec 2025 22:48:04 +0530 Subject: [PATCH 1/4] added functionality to add screenshots in run and tests --- .dockerignore | 1 - MIGRATIONS.md | 206 ++++++ .../Attachments/AttachmentSection.tsx | 44 ++ .../Attachments/DeleteConfirmDialog.tsx | 61 ++ app/components/Attachments/MediaGallery.tsx | 299 +++++++++ app/components/Attachments/MediaUploader.tsx | 321 +++++++++ app/components/Attachments/MediaViewer.tsx | 246 +++++++ app/components/Attachments/index.ts | 13 + app/components/Attachments/types.ts | 101 +++ app/dataController/attachments.controller.ts | 619 ++++++++++++++++++ app/db/dao/runAttachments.dao.ts | 362 ++++++++++ app/db/dao/testAttachments.dao.ts | 326 +++++++++ app/db/schema/attachments.ts | 142 ++++ app/routes/api.v1.attachments.serve.$.ts | 80 +++ app/routes/api/v1/confirmAttachmentUpload.ts | 85 +++ app/routes/api/v1/deleteRunAttachment.ts | 62 ++ app/routes/api/v1/deleteTestAttachment.ts | 62 ++ app/routes/api/v1/presignedUploadUrl.ts | 79 +++ app/routes/api/v1/runAttachments.ts | 58 ++ app/routes/api/v1/testAttachments.ts | 53 ++ app/routes/api/v1/uploadRunAttachment.ts | 179 +++++ app/routes/api/v1/uploadTestAttachment.ts | 169 +++++ app/routes/utilities/api.ts | 27 + app/screens/CreateTest/EditTestPage.tsx | 153 ++++- app/screens/TestDetail/TestDetailsPage.tsx | 133 +++- .../TestList/TestDetailSlidingPanel.tsx | 101 ++- app/services/rbac/rbacPolicyGeneration.ts | 18 + app/services/storage/StorageFactory.ts | 130 ++++ app/services/storage/index.ts | 44 ++ .../storage/interfaces/StorageProvider.ts | 235 +++++++ .../storage/providers/GCSStorageProvider.ts | 273 ++++++++ .../storage/providers/LocalStorageProvider.ts | 228 +++++++ .../storage/providers/S3StorageProvider.ts | 306 +++++++++ db-migration.ts | 32 +- docker-compose.yml | 3 +- dockerfile | 10 +- .../tools/confirmAttachmentUpload/index.ts | 82 +++ .../src/tools/deleteRunAttachment/index.ts | 48 ++ .../src/tools/deleteTestAttachment/index.ts | 48 ++ .../src/tools/getPresignedUploadUrl/index.ts | 99 +++ .../src/tools/getRunAttachments/index.ts | 49 ++ .../src/tools/getTestAttachments/index.ts | 46 ++ package.json | 1 + scripts/docker-entrypoint.sh | 65 ++ scripts/migration-runner.ts | 227 +++++++ seedData.sql | 87 +++ sql/add_attachments_tables.sql | 85 +++ vite.config.ts | 18 +- 48 files changed, 6104 insertions(+), 12 deletions(-) create mode 100644 MIGRATIONS.md create mode 100644 app/components/Attachments/AttachmentSection.tsx create mode 100644 app/components/Attachments/DeleteConfirmDialog.tsx create mode 100644 app/components/Attachments/MediaGallery.tsx create mode 100644 app/components/Attachments/MediaUploader.tsx create mode 100644 app/components/Attachments/MediaViewer.tsx create mode 100644 app/components/Attachments/index.ts create mode 100644 app/components/Attachments/types.ts create mode 100644 app/dataController/attachments.controller.ts create mode 100644 app/db/dao/runAttachments.dao.ts create mode 100644 app/db/dao/testAttachments.dao.ts create mode 100644 app/db/schema/attachments.ts create mode 100644 app/routes/api.v1.attachments.serve.$.ts create mode 100644 app/routes/api/v1/confirmAttachmentUpload.ts create mode 100644 app/routes/api/v1/deleteRunAttachment.ts create mode 100644 app/routes/api/v1/deleteTestAttachment.ts create mode 100644 app/routes/api/v1/presignedUploadUrl.ts create mode 100644 app/routes/api/v1/runAttachments.ts create mode 100644 app/routes/api/v1/testAttachments.ts create mode 100644 app/routes/api/v1/uploadRunAttachment.ts create mode 100644 app/routes/api/v1/uploadTestAttachment.ts create mode 100644 app/services/storage/StorageFactory.ts create mode 100644 app/services/storage/index.ts create mode 100644 app/services/storage/interfaces/StorageProvider.ts create mode 100644 app/services/storage/providers/GCSStorageProvider.ts create mode 100644 app/services/storage/providers/LocalStorageProvider.ts create mode 100644 app/services/storage/providers/S3StorageProvider.ts create mode 100644 mcp-server/src/tools/confirmAttachmentUpload/index.ts create mode 100644 mcp-server/src/tools/deleteRunAttachment/index.ts create mode 100644 mcp-server/src/tools/deleteTestAttachment/index.ts create mode 100644 mcp-server/src/tools/getPresignedUploadUrl/index.ts create mode 100644 mcp-server/src/tools/getRunAttachments/index.ts create mode 100644 mcp-server/src/tools/getTestAttachments/index.ts create mode 100644 scripts/docker-entrypoint.sh create mode 100644 scripts/migration-runner.ts create mode 100644 sql/add_attachments_tables.sql diff --git a/.dockerignore b/.dockerignore index 5535e72..584b7d8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,7 +6,6 @@ logs *.log Dockerfile .dockerignore -drizzle app/db/seed/seedTests/*.json .cache .idea/* diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..d71afe0 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,206 @@ +# Database Migrations Guide + +This document explains how database migrations work in Checkmate and how to add new schema changes. + +## Overview + +Checkmate uses [Drizzle ORM](https://orm.drizzle.team/) for database migrations. The migration system is designed to work seamlessly with both: + +- **Fresh Docker deployments** (new databases) +- **Existing databases** (upgrades) + +## How It Works + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Migration Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Fresh Deployment Existing Database │ +│ ───────────────── ───────────────── │ +│ │ │ │ +│ ▼ │ │ +│ seedData.sql runs │ │ +│ (creates all tables) │ │ +│ │ │ │ +│ ▼ ▼ │ +│ migration-runner.ts ◄────────► migration-runner.ts │ +│ │ │ │ +│ ▼ ▼ │ +│ Detects tables exist Checks __drizzle_migrations │ +│ Marks as "baseline" Runs only pending migrations │ +│ │ │ │ +│ ▼ ▼ │ +│ App Starts App Starts │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Migration Files + +Migrations are stored in the `drizzle/` directory: + +``` +drizzle/ +├── 0000_lush_lyja.sql # First migration (attachments) +├── 0001_next_migration.sql # Future migrations... +└── meta/ + ├── _journal.json # Migration registry + └── 0000_snapshot.json # Schema snapshots +``` + +## Adding New Migrations + +### Step 1: Update the Schema + +Edit or create schema files in `app/db/schema/`: + +```typescript +// app/db/schema/newFeature.ts +import { mysqlTable, int, varchar } from 'drizzle-orm/mysql-core' + +export const newTable = mysqlTable('newTable', { + id: int('id').primaryKey().autoincrement(), + name: varchar('name', { length: 255 }).notNull(), + // ... more columns +}) +``` + +### Step 2: Generate Migration + +```bash +yarn db:generate +``` + +This creates a new migration file in `drizzle/` with only the incremental changes. + +### Step 3: Review the Migration + +Check the generated SQL file to ensure it only contains your new changes: + +```bash +cat drizzle/0001_*.sql +``` + +### Step 4: Test Locally + +```bash +# Apply migration to your local database +yarn db:migrate + +# Or use the production runner +yarn db:migrate:run +``` + +### Step 5: Update seedData.sql + +For fresh deployments, add the new table(s) to `seedData.sql`: + +```sql +-- Add after existing table definitions + +CREATE TABLE `newTable` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +``` + +### Step 6: Commit and Deploy + +```bash +git add drizzle/ seedData.sql app/db/schema/ +git commit -m "feat: add newTable migration" +``` + +## Available Commands + +| Command | Description | +|---------|-------------| +| `yarn db:generate` | Generate migration from schema changes | +| `yarn db:migrate` | Run migrations (Drizzle default) | +| `yarn db:migrate:run` | Run migrations (production runner with baseline support) | +| `yarn db:seed` | Seed database with test data | + +## Docker Deployment + +When Docker containers start, the entrypoint script automatically: + +1. Waits for database connectivity +2. Runs `migration-runner.ts` +3. Starts the application + +```bash +# Deploy with automatic migrations +docker-compose up --build -d + +# Check migration logs +docker logs checkmate-app | grep -A 20 "Migration" +``` + +## Troubleshooting + +### "Table already exists" Error + +This happens when Drizzle tries to create tables that already exist. Solutions: + +1. **For development**: Drop and recreate the database +2. **For production**: The migration-runner handles this automatically by detecting existing tables + +### Migration Not Applied + +Check if the migration is tracked: + +```sql +SELECT * FROM __drizzle_migrations; +``` + +If missing, you can manually mark it: + +```sql +INSERT INTO __drizzle_migrations (hash, created_at) +VALUES ('0001_migration_tag', UNIX_TIMESTAMP() * 1000); +``` + +### Reset Migrations (Development Only) + +```bash +# Remove migration tracking +mysql -e "DROP TABLE IF EXISTS __drizzle_migrations" your_database + +# Remove migration files +rm -rf drizzle/ + +# Regenerate from current schema +yarn db:generate +``` + +## Best Practices + +1. **One Change Per Migration**: Keep migrations focused on a single feature +2. **Test Before Deploy**: Always test migrations on a copy of production data +3. **Backwards Compatible**: Avoid breaking changes to existing columns +4. **Document Changes**: Add comments in migration SQL files +5. **Keep seedData.sql Updated**: Always sync fresh deployment schema + +## Architecture + +``` +┌────────────────────┐ ┌────────────────────┐ +│ Schema Files │ │ Migration Files │ +│ app/db/schema/* │────▶│ drizzle/*.sql │ +└────────────────────┘ └────────────────────┘ + │ │ + │ ▼ + │ ┌────────────────────┐ + │ │ migration-runner │ + │ │ (Docker startup) │ + │ └────────────────────┘ + │ │ + ▼ ▼ +┌────────────────────┐ ┌────────────────────┐ +│ seedData.sql │ │ MySQL Database │ +│ (fresh deploys) │────▶│ __drizzle_migrations +└────────────────────┘ └────────────────────┘ +``` + diff --git a/app/components/Attachments/AttachmentSection.tsx b/app/components/Attachments/AttachmentSection.tsx new file mode 100644 index 0000000..1d00c13 --- /dev/null +++ b/app/components/Attachments/AttachmentSection.tsx @@ -0,0 +1,44 @@ +/** + * Attachment Section Component + * + * A section wrapper for displaying attachments with a title and optional actions. + */ + +import {cn} from '~/ui/utils' +import {MediaGallery} from './MediaGallery' +import {AttachmentSectionProps} from './types' + +export function AttachmentSection({ + title, + attachments, + onDelete, + canDelete = false, + children, + emptyMessage, +}: AttachmentSectionProps) { + return ( +
+
+

+ {title} + {attachments.length > 0 && ( + + {attachments.length} + + )} +

+ {children} +
+ + +
+ ) +} + +export default AttachmentSection + diff --git a/app/components/Attachments/DeleteConfirmDialog.tsx b/app/components/Attachments/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..455a143 --- /dev/null +++ b/app/components/Attachments/DeleteConfirmDialog.tsx @@ -0,0 +1,61 @@ +/** + * Delete Confirmation Dialog + * + * Shows a confirmation dialog before deleting an attachment. + */ + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '~/ui/alert-dialog' +import {Attachment} from './types' + +interface DeleteConfirmDialogProps { + attachment: Attachment | null + isOpen: boolean + onClose: () => void + onConfirm: () => void +} + +export function DeleteConfirmDialog({ + attachment, + isOpen, + onClose, + onConfirm, +}: DeleteConfirmDialogProps) { + if (!attachment) return null + + return ( + !open && onClose()}> + + + Delete Attachment? + + Are you sure you want to delete{' '} + + "{attachment.originalFilename}" + + ? This action cannot be undone. + + + + Cancel + + Delete + + + + + ) +} + +export default DeleteConfirmDialog + diff --git a/app/components/Attachments/MediaGallery.tsx b/app/components/Attachments/MediaGallery.tsx new file mode 100644 index 0000000..849a3e7 --- /dev/null +++ b/app/components/Attachments/MediaGallery.tsx @@ -0,0 +1,299 @@ +/** + * Media Gallery Component + * + * Displays a grid of media thumbnails with lightbox viewing and navigation. + * Supports soft delete (staging deletions until form submission). + */ + +import {useState, useCallback} from 'react' +import {ImageIcon, VideoIcon, Trash2, Undo2} from 'lucide-react' +import {cn} from '~/ui/utils' +import { + MediaGalleryProps, + Attachment, + formatFileSize, + isImageType, + isVideoType, +} from './types' +import {MediaViewer} from './MediaViewer' +import {DeleteConfirmDialog} from './DeleteConfirmDialog' + +export function MediaGallery({ + attachments, + onDelete, + canDelete = false, + emptyMessage = 'No attachments', + title, + pendingDeletions = new Set(), + onUndoDelete, +}: MediaGalleryProps) { + const [selectedIndex, setSelectedIndex] = useState(null) + const [attachmentToDelete, setAttachmentToDelete] = useState(null) + + // Filter out pending deletions for display count + const activeAttachments = attachments.filter( + (a) => !pendingDeletions.has(a.attachmentId) + ) + const deletedCount = pendingDeletions.size + + const handleThumbnailClick = useCallback((index: number) => { + // Don't open viewer for soft-deleted attachments + const attachment = attachments[index] + if (pendingDeletions.has(attachment.attachmentId)) return + setSelectedIndex(index) + }, [attachments, pendingDeletions]) + + const handleCloseViewer = useCallback(() => { + setSelectedIndex(null) + }, []) + + const handleNavigate = useCallback((index: number) => { + // Skip soft-deleted attachments during navigation + const attachment = attachments[index] + if (attachment && !pendingDeletions.has(attachment.attachmentId)) { + setSelectedIndex(index) + } + }, [attachments, pendingDeletions]) + + // Show delete confirmation dialog + const handleRequestDelete = useCallback((attachment: Attachment) => { + setAttachmentToDelete(attachment) + }, []) + + // Close delete confirmation dialog + const handleCancelDelete = useCallback(() => { + setAttachmentToDelete(null) + }, []) + + // Confirm and execute delete (soft delete) + const handleConfirmDelete = useCallback(() => { + if (attachmentToDelete && onDelete) { + onDelete(attachmentToDelete.attachmentId) + + // Close viewer if the deleted attachment was selected + if (selectedIndex !== null) { + const selectedAttachment = attachments[selectedIndex] + if (selectedAttachment?.attachmentId === attachmentToDelete.attachmentId) { + setSelectedIndex(null) + } + } + } + setAttachmentToDelete(null) + }, [attachmentToDelete, onDelete, selectedIndex, attachments]) + + // Handle undo delete + const handleUndoDelete = useCallback((attachmentId: number) => { + onUndoDelete?.(attachmentId) + }, [onUndoDelete]) + + if (attachments.length === 0) { + return ( +
+ +

{emptyMessage}

+
+ ) + } + + const selectedAttachment = selectedIndex !== null ? attachments[selectedIndex] : null + + return ( +
+ {title && ( +

{title}

+ )} + + {/* Pending deletions notice */} + {deletedCount > 0 && ( +
+ + {deletedCount} attachment{deletedCount !== 1 ? 's' : ''} marked for deletion. + + + Changes will be applied when you save. + +
+ )} + +
+ {attachments.map((attachment, index) => { + const isPendingDelete = pendingDeletions.has(attachment.attachmentId) + return ( + handleThumbnailClick(index)} + onDelete={canDelete && !isPendingDelete ? () => handleRequestDelete(attachment) : undefined} + onUndoDelete={isPendingDelete ? () => handleUndoDelete(attachment.attachmentId) : undefined} + canDelete={canDelete} + isPendingDelete={isPendingDelete} + /> + ) + })} +
+ + {/* Media Viewer Modal with Navigation */} + {selectedAttachment && selectedIndex !== null && !pendingDeletions.has(selectedAttachment.attachmentId) && ( + a.attachmentId === selectedAttachment.attachmentId)} + isOpen={true} + onClose={handleCloseViewer} + onNavigate={(newIndex) => { + const newAttachment = activeAttachments[newIndex] + if (newAttachment) { + const originalIndex = attachments.findIndex(a => a.attachmentId === newAttachment.attachmentId) + setSelectedIndex(originalIndex) + } + }} + onDelete={ + canDelete ? () => handleRequestDelete(selectedAttachment) : undefined + } + canDelete={canDelete} + /> + )} + + {/* Delete Confirmation Dialog */} + +
+ ) +} + +interface MediaThumbnailProps { + attachment: Attachment + onClick: () => void + onDelete?: () => void + onUndoDelete?: () => void + canDelete?: boolean + isPendingDelete?: boolean +} + +function MediaThumbnail({ + attachment, + onClick, + onDelete, + onUndoDelete, + canDelete, + isPendingDelete = false, +}: MediaThumbnailProps) { + const isImage = isImageType(attachment.mimeType) + const isVideo = isVideoType(attachment.mimeType) + + const handleDeleteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onDelete?.() + }, + [onDelete], + ) + + const handleUndoClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onUndoDelete?.() + }, + [onUndoDelete], + ) + + return ( +
+ {/* Thumbnail */} + {isImage ? ( + {attachment.originalFilename} + ) : isVideo ? ( +
+ +
+ ) : ( +
+ +
+ )} + + {/* Pending delete overlay */} + {isPendingDelete && ( +
+
+ + + Marked for deletion + +
+
+ )} + + {/* Undo button for pending deletions */} + {isPendingDelete && onUndoDelete && ( + + )} + + {/* Normal overlay on hover (only for non-deleted) */} + {!isPendingDelete && ( +
+
+ {attachment.originalFilename} +
+ + {canDelete && onDelete && ( + + )} +
+ )} + + {/* Video indicator */} + {isVideo && !isPendingDelete && ( +
+ Video +
+ )} + + {/* File size indicator */} + {!isPendingDelete && ( +
+ {formatFileSize(attachment.fileSize)} +
+ )} +
+ ) +} + +export default MediaGallery diff --git a/app/components/Attachments/MediaUploader.tsx b/app/components/Attachments/MediaUploader.tsx new file mode 100644 index 0000000..eb66af4 --- /dev/null +++ b/app/components/Attachments/MediaUploader.tsx @@ -0,0 +1,321 @@ +/** + * Media Uploader Component + * + * Drag-and-drop file uploader for test and run attachments. + * Supports images (PNG, JPEG, GIF, WebP) and videos (MP4, WebM, MOV). + */ + +import {useCallback, useState, useRef} from 'react' +import {useFetcher} from '@remix-run/react' +import {Upload, X, FileImage, FileVideo, AlertCircle} from 'lucide-react' +import {Button} from '~/ui/button' +import {cn} from '~/ui/utils' +import {API} from '~/routes/utilities/api' +import { + MediaUploaderProps, + Attachment, + AttachmentUploadProgress, + formatFileSize, + isImageType, + isVideoType, +} from './types' + +const MAX_FILE_SIZE_IMAGE = 10 * 1024 * 1024 // 10MB +const MAX_FILE_SIZE_VIDEO = 100 * 1024 * 1024 // 100MB +const MAX_ATTACHMENTS = 10 + +const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'] +const ACCEPTED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'] +const ACCEPTED_TYPES = [...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_VIDEO_TYPES] + +export function MediaUploader({ + projectId, + testId, + runId, + attachmentType, + onUploadComplete, + onUploadError, + disabled = false, + maxFiles = MAX_ATTACHMENTS, + currentCount = 0, +}: MediaUploaderProps) { + const [isDragOver, setIsDragOver] = useState(false) + const [uploadQueue, setUploadQueue] = useState([]) + const fileInputRef = useRef(null) + const uploadFetcher = useFetcher<{data?: Attachment; error?: string}>() + + const remainingSlots = maxFiles - currentCount + + const validateFile = useCallback( + (file: File): string | null => { + if (!ACCEPTED_TYPES.includes(file.type)) { + return `Unsupported file type: ${file.type}` + } + + const maxSize = isImageType(file.type) + ? MAX_FILE_SIZE_IMAGE + : MAX_FILE_SIZE_VIDEO + + if (file.size > maxSize) { + return `File too large. Maximum size: ${formatFileSize(maxSize)}` + } + + return null + }, + [], + ) + + const uploadFile = useCallback( + async (file: File) => { + const validationError = validateFile(file) + if (validationError) { + onUploadError?.(validationError) + return + } + + // Update queue with uploading status + setUploadQueue((prev) => [ + ...prev, + {filename: file.name, progress: 0, status: 'uploading'}, + ]) + + const formData = new FormData() + formData.append('file', file) + + // Build URL with query params (multipart form data doesn't preserve text fields reliably) + const params = new URLSearchParams({ + projectId: String(projectId), + testId: String(testId), + }) + if (runId) { + params.append('runId', String(runId)) + } + + const baseUrl = + attachmentType === 'expected' + ? `/${API.UploadTestAttachment}` + : `/${API.UploadRunAttachment}` + + const uploadUrl = `${baseUrl}?${params.toString()}` + + try { + const response = await fetch(uploadUrl, { + method: 'POST', + body: formData, + }) + + const result = await response.json() + + if (result.error) { + setUploadQueue((prev) => + prev.map((item) => + item.filename === file.name + ? {...item, status: 'error', error: result.error} + : item, + ), + ) + onUploadError?.(result.error) + } else if (result.data) { + setUploadQueue((prev) => + prev.map((item) => + item.filename === file.name + ? {...item, status: 'completed', progress: 100} + : item, + ), + ) + onUploadComplete?.(result.data) + + // Remove from queue after a delay + setTimeout(() => { + setUploadQueue((prev) => + prev.filter((item) => item.filename !== file.name), + ) + }, 2000) + } + } catch (error: any) { + setUploadQueue((prev) => + prev.map((item) => + item.filename === file.name + ? {...item, status: 'error', error: error.message} + : item, + ), + ) + onUploadError?.(error.message) + } + }, + [projectId, testId, runId, attachmentType, onUploadComplete, onUploadError, validateFile], + ) + + const handleFiles = useCallback( + (files: FileList | null) => { + if (!files || disabled || remainingSlots <= 0) return + + const filesToUpload = Array.from(files).slice(0, remainingSlots) + + for (const file of filesToUpload) { + uploadFile(file) + } + }, + [uploadFile, disabled, remainingSlots], + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + if (!disabled && remainingSlots > 0) { + setIsDragOver(true) + } + }, + [disabled, remainingSlots], + ) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + handleFiles(e.dataTransfer.files) + }, + [handleFiles], + ) + + const handleClick = useCallback(() => { + if (!disabled && remainingSlots > 0) { + fileInputRef.current?.click() + } + }, [disabled, remainingSlots]) + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + handleFiles(e.target.files) + // Reset input so same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }, + [handleFiles], + ) + + const removeFromQueue = useCallback((filename: string) => { + setUploadQueue((prev) => prev.filter((item) => item.filename !== filename)) + }, []) + + const isDisabled = disabled || remainingSlots <= 0 + + return ( +
+ {/* Dropzone */} +
+ + + + +
+

+ {isDragOver && !isDisabled + ? 'Drop files here' + : 'Drag & drop files or click to browse'} +

+

+ Images (PNG, JPEG, GIF, WebP) up to 10MB • Videos (MP4, WebM, MOV) up to + 100MB +

+ {remainingSlots < maxFiles && ( +

+ {remainingSlots > 0 + ? `${remainingSlots} slot${remainingSlots !== 1 ? 's' : ''} remaining` + : 'Maximum attachments reached'} +

+ )} +
+
+ + {/* Upload Queue */} + {uploadQueue.length > 0 && ( +
+ {uploadQueue.map((item) => ( +
+ {/* File icon */} +
+ {item.status === 'error' ? ( + + ) : ( + + )} +
+ + {/* File info */} +
+

+ {item.filename} +

+ {item.status === 'error' ? ( +

{item.error}

+ ) : item.status === 'uploading' ? ( +
+
+
+ ) : ( +

Upload complete

+ )} +
+ + {/* Remove button */} + {(item.status === 'error' || item.status === 'completed') && ( + + )} +
+ ))} +
+ )} +
+ ) +} + +export default MediaUploader + diff --git a/app/components/Attachments/MediaViewer.tsx b/app/components/Attachments/MediaViewer.tsx new file mode 100644 index 0000000..c479938 --- /dev/null +++ b/app/components/Attachments/MediaViewer.tsx @@ -0,0 +1,246 @@ +/** + * Media Viewer Component + * + * Full-screen lightbox for viewing images and videos with navigation. + */ + +import {useCallback, useEffect} from 'react' +import {X, Trash2, Download, ExternalLink, ChevronLeft, ChevronRight} from 'lucide-react' +import {Button} from '~/ui/button' +import {cn} from '~/ui/utils' +import { + MediaViewerProps, + formatFileSize, + isImageType, + isVideoType, +} from './types' + +export function MediaViewer({ + attachment, + attachments = [], + currentIndex = 0, + isOpen, + onClose, + onDelete, + canDelete = false, + onNavigate, +}: MediaViewerProps) { + const isImage = isImageType(attachment.mimeType) + const isVideo = isVideoType(attachment.mimeType) + + // Navigation state + const hasMultiple = attachments.length > 1 + const canGoPrev = hasMultiple && currentIndex > 0 + const canGoNext = hasMultiple && currentIndex < attachments.length - 1 + + const handlePrev = useCallback(() => { + if (canGoPrev && onNavigate) { + onNavigate(currentIndex - 1) + } + }, [canGoPrev, currentIndex, onNavigate]) + + const handleNext = useCallback(() => { + if (canGoNext && onNavigate) { + onNavigate(currentIndex + 1) + } + }, [canGoNext, currentIndex, onNavigate]) + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return + + switch (e.key) { + case 'Escape': + onClose() + break + case 'ArrowLeft': + handlePrev() + break + case 'ArrowRight': + handleNext() + break + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose, handlePrev, handleNext]) + + // Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + }, + [onClose], + ) + + const handleDownload = useCallback(() => { + const link = document.createElement('a') + link.href = attachment.url + link.download = attachment.originalFilename + link.target = '_blank' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + }, [attachment]) + + const handleOpenInNewTab = useCallback(() => { + window.open(attachment.url, '_blank') + }, [attachment.url]) + + if (!isOpen) return null + + return ( +
+ {/* Close button */} + + + {/* Counter badge */} + {hasMultiple && ( +
+ {currentIndex + 1} / {attachments.length} +
+ )} + + {/* Previous button */} + {hasMultiple && ( + + )} + + {/* Next button */} + {hasMultiple && ( + + )} + + {/* Media content */} +
+ {/* Media container */} +
+ {isImage ? ( + {attachment.originalFilename} + ) : isVideo ? ( +
+ + {/* Info bar */} +
+
+

{attachment.originalFilename}

+

+ {formatFileSize(attachment.fileSize)} • {attachment.mimeType} +

+ {attachment.description && ( +

+ {attachment.description} +

+ )} +
+ +
+ + + + + {canDelete && onDelete && ( + + )} +
+
+ + {/* Keyboard hint */} + {hasMultiple && ( +

+ Use ← → arrow keys to navigate • ESC to close +

+ )} +
+
+ ) +} + +export default MediaViewer diff --git a/app/components/Attachments/index.ts b/app/components/Attachments/index.ts new file mode 100644 index 0000000..a080eb5 --- /dev/null +++ b/app/components/Attachments/index.ts @@ -0,0 +1,13 @@ +/** + * Attachments Components + * + * Exports all attachment-related components. + */ + +export {MediaUploader} from './MediaUploader' +export {MediaGallery} from './MediaGallery' +export {MediaViewer} from './MediaViewer' +export {AttachmentSection} from './AttachmentSection' +export {DeleteConfirmDialog} from './DeleteConfirmDialog' +export * from './types' + diff --git a/app/components/Attachments/types.ts b/app/components/Attachments/types.ts new file mode 100644 index 0000000..38ad269 --- /dev/null +++ b/app/components/Attachments/types.ts @@ -0,0 +1,101 @@ +/** + * Attachment Component Types + * + * Type definitions for attachment-related components. + */ + +export interface Attachment { + attachmentId: number + testId: number + projectId: number + runId?: number + storageKey: string + originalFilename: string + mimeType: string + fileSize: number + mediaType: 'image' | 'video' + description: string | null + displayOrder: number | null + url: string + createdOn: Date | string +} + +export interface AttachmentUploadProgress { + filename: string + progress: number // 0-100 + status: 'pending' | 'uploading' | 'completed' | 'error' + error?: string +} + +export interface MediaUploaderProps { + projectId: number + testId: number + runId?: number + attachmentType: 'expected' | 'actual' + onUploadComplete?: (attachment: Attachment) => void + onUploadError?: (error: string) => void + disabled?: boolean + maxFiles?: number + currentCount?: number +} + +export interface MediaGalleryProps { + attachments: Attachment[] + onDelete?: (attachmentId: number) => void + canDelete?: boolean + emptyMessage?: string + title?: string + // Soft delete support + pendingDeletions?: Set // IDs of attachments marked for deletion + onUndoDelete?: (attachmentId: number) => void // Restore a soft-deleted attachment +} + +export interface MediaViewerProps { + attachment: Attachment + attachments?: Attachment[] // All attachments for navigation + currentIndex?: number // Current attachment index + isOpen: boolean + onClose: () => void + onDelete?: () => void + canDelete?: boolean + onNavigate?: (index: number) => void // Navigate to specific index +} + +export interface MediaThumbnailProps { + attachment: Attachment + onClick?: () => void + onDelete?: () => void + canDelete?: boolean + size?: 'sm' | 'md' | 'lg' +} + +export interface AttachmentSectionProps { + title: string + attachments: Attachment[] + onDelete?: (attachmentId: number) => void + canDelete?: boolean + children?: React.ReactNode + emptyMessage?: string +} + +// Helper functions +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +export function getFileExtension(filename: string): string { + return filename.slice(((filename.lastIndexOf('.') - 1) >>> 0) + 2) +} + +export function isImageType(mimeType: string): boolean { + return mimeType.startsWith('image/') +} + +export function isVideoType(mimeType: string): boolean { + return mimeType.startsWith('video/') +} + diff --git a/app/dataController/attachments.controller.ts b/app/dataController/attachments.controller.ts new file mode 100644 index 0000000..48989ef --- /dev/null +++ b/app/dataController/attachments.controller.ts @@ -0,0 +1,619 @@ +/** + * Attachments Controller + * + * Business logic layer for handling test and run attachments. + * Orchestrates storage operations and database operations. + */ + +import TestAttachmentsDao from '~/db/dao/testAttachments.dao' +import RunAttachmentsDao from '~/db/dao/runAttachments.dao' +import { + getStorageProvider, + getMediaTypeFromMime, + isValidFileSize, + isSupportedMediaType, + generateStorageKey, + AttachmentType, + MediaType, + MAX_ATTACHMENTS, + FILE_SIZE_LIMITS, +} from '~/services/storage' +import {logger, LogType} from '~/utils/logger' +import {ErrorCause} from '~/constants' + +// Types +export interface IUploadTestAttachment { + testId: number + projectId: number + file: Buffer + filename: string + mimeType: string + fileSize: number + description?: string + createdBy: number +} + +export interface IUploadRunAttachment { + runId: number + testId: number + testRunMapId?: number + projectId: number + file: Buffer + filename: string + mimeType: string + fileSize: number + description?: string + createdBy: number +} + +export interface IGetPresignedUploadUrl { + testId: number + projectId: number + runId?: number + filename: string + mimeType: string + fileSize: number + attachmentType: AttachmentType +} + +export interface IConfirmUpload { + testId: number + projectId: number + runId?: number + storageKey: string + filename: string + mimeType: string + fileSize: number + description?: string + createdBy: number + attachmentType: AttachmentType + testRunMapId?: number +} + +export interface IDeleteAttachment { + attachmentId: number + projectId: number + attachmentType: AttachmentType +} + +export interface IGetAttachments { + testId: number + projectId: number + runId?: number +} + +export interface AttachmentResponse { + attachmentId: number + testId: number + projectId: number + runId?: number + storageKey: string + originalFilename: string + mimeType: string + fileSize: number + mediaType: 'image' | 'video' + description: string | null + displayOrder: number | null + url: string + createdOn: Date +} + +const AttachmentsController = { + /** + * Validate file for upload + */ + validateFile(mimeType: string, fileSize: number): void { + if (!isSupportedMediaType(mimeType)) { + throw new Error( + `Unsupported file type: ${mimeType}. Supported types: images (PNG, JPEG, GIF, WebP) and videos (MP4, WebM, MOV)`, + {cause: ErrorCause.INVALID_PARAMS}, + ) + } + + const mediaType = getMediaTypeFromMime(mimeType) + if (!mediaType) { + throw new Error(`Could not determine media type for: ${mimeType}`, { + cause: ErrorCause.INVALID_PARAMS, + }) + } + + if (!isValidFileSize(fileSize, mediaType)) { + const maxSize = + mediaType === MediaType.IMAGE + ? `${FILE_SIZE_LIMITS.IMAGE / (1024 * 1024)}MB` + : `${FILE_SIZE_LIMITS.VIDEO / (1024 * 1024)}MB` + throw new Error(`File size exceeds limit. Maximum size for ${mediaType}: ${maxSize}`, { + cause: ErrorCause.INVALID_PARAMS, + }) + } + }, + + /** + * Upload a test attachment (expected behavior) + */ + async uploadTestAttachment(params: IUploadTestAttachment): Promise { + // Validate file + this.validateFile(params.mimeType, params.fileSize) + + // Check attachment count limit + const currentCount = await TestAttachmentsDao.countAttachments(params.testId) + if (currentCount >= MAX_ATTACHMENTS.PER_TEST) { + throw new Error( + `Maximum attachments (${MAX_ATTACHMENTS.PER_TEST}) reached for this test`, + {cause: ErrorCause.INVALID_PARAMS}, + ) + } + + const storage = await getStorageProvider() + const mediaType = getMediaTypeFromMime(params.mimeType)! + + // Generate storage key + const storageKey = generateStorageKey({ + projectId: params.projectId, + testId: params.testId, + filename: params.filename, + attachmentType: AttachmentType.EXPECTED, + }) + + try { + // Upload to storage + await storage.upload(params.file, storageKey, { + contentType: params.mimeType, + originalFilename: params.filename, + size: params.fileSize, + }) + + // Create database record + const {attachmentId} = await TestAttachmentsDao.createAttachment({ + testId: params.testId, + projectId: params.projectId, + storageKey, + originalFilename: params.filename, + mimeType: params.mimeType, + fileSize: params.fileSize, + mediaType, + description: params.description, + createdBy: params.createdBy, + }) + + // Get the created attachment + const attachment = await TestAttachmentsDao.getAttachmentById(attachmentId) + if (!attachment) { + throw new Error('Failed to retrieve created attachment') + } + + // Generate URL + const url = await storage.getSignedUrl(storageKey) + + logger({ + type: LogType.INFO, + tag: 'AttachmentsController', + message: `Uploaded test attachment ${attachmentId} for test ${params.testId}`, + }) + + return { + attachmentId: attachment.attachmentId, + testId: attachment.testId, + projectId: attachment.projectId, + storageKey: attachment.storageKey, + originalFilename: attachment.originalFilename, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + mediaType: attachment.mediaType, + description: attachment.description, + displayOrder: attachment.displayOrder, + url, + createdOn: attachment.createdOn, + } + } catch (error: any) { + // Clean up storage if database operation failed + try { + await storage.delete(storageKey) + } catch (cleanupError) { + logger({ + type: LogType.ERROR, + tag: 'AttachmentsController', + message: `Failed to cleanup storage after error: ${cleanupError}`, + }) + } + throw error + } + }, + + /** + * Upload a run attachment (actual behavior) + */ + async uploadRunAttachment(params: IUploadRunAttachment): Promise { + // Validate file + this.validateFile(params.mimeType, params.fileSize) + + // Check attachment count limit + const currentCount = await RunAttachmentsDao.countAttachments( + params.runId, + params.testId, + ) + if (currentCount >= MAX_ATTACHMENTS.PER_RUN_TEST) { + throw new Error( + `Maximum attachments (${MAX_ATTACHMENTS.PER_RUN_TEST}) reached for this test in this run`, + {cause: ErrorCause.INVALID_PARAMS}, + ) + } + + const storage = await getStorageProvider() + const mediaType = getMediaTypeFromMime(params.mimeType)! + + // Generate storage key + const storageKey = generateStorageKey({ + projectId: params.projectId, + testId: params.testId, + runId: params.runId, + filename: params.filename, + attachmentType: AttachmentType.ACTUAL, + }) + + try { + // Upload to storage + await storage.upload(params.file, storageKey, { + contentType: params.mimeType, + originalFilename: params.filename, + size: params.fileSize, + }) + + // Create database record + const {attachmentId} = await RunAttachmentsDao.createAttachment({ + runId: params.runId, + testId: params.testId, + testRunMapId: params.testRunMapId, + projectId: params.projectId, + storageKey, + originalFilename: params.filename, + mimeType: params.mimeType, + fileSize: params.fileSize, + mediaType, + description: params.description, + createdBy: params.createdBy, + }) + + // Get the created attachment + const attachment = await RunAttachmentsDao.getAttachmentById(attachmentId) + if (!attachment) { + throw new Error('Failed to retrieve created attachment') + } + + // Generate URL + const url = await storage.getSignedUrl(storageKey) + + logger({ + type: LogType.INFO, + tag: 'AttachmentsController', + message: `Uploaded run attachment ${attachmentId} for run ${params.runId}, test ${params.testId}`, + }) + + return { + attachmentId: attachment.attachmentId, + testId: attachment.testId, + projectId: attachment.projectId, + runId: attachment.runId, + storageKey: attachment.storageKey, + originalFilename: attachment.originalFilename, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + mediaType: attachment.mediaType, + description: attachment.description, + displayOrder: attachment.displayOrder, + url, + createdOn: attachment.createdOn, + } + } catch (error: any) { + // Clean up storage if database operation failed + try { + await storage.delete(storageKey) + } catch (cleanupError) { + logger({ + type: LogType.ERROR, + tag: 'AttachmentsController', + message: `Failed to cleanup storage after error: ${cleanupError}`, + }) + } + throw error + } + }, + + /** + * Get presigned URL for direct client upload + */ + async getPresignedUploadUrl(params: IGetPresignedUploadUrl): Promise<{ + uploadUrl: string + storageKey: string + expiresAt: Date + }> { + // Validate file parameters + this.validateFile(params.mimeType, params.fileSize) + + // Check attachment count limit + if (params.attachmentType === AttachmentType.EXPECTED) { + const currentCount = await TestAttachmentsDao.countAttachments(params.testId) + if (currentCount >= MAX_ATTACHMENTS.PER_TEST) { + throw new Error( + `Maximum attachments (${MAX_ATTACHMENTS.PER_TEST}) reached for this test`, + {cause: ErrorCause.INVALID_PARAMS}, + ) + } + } else { + if (!params.runId) { + throw new Error('runId is required for actual behavior attachments', { + cause: ErrorCause.INVALID_PARAMS, + }) + } + const currentCount = await RunAttachmentsDao.countAttachments( + params.runId, + params.testId, + ) + if (currentCount >= MAX_ATTACHMENTS.PER_RUN_TEST) { + throw new Error( + `Maximum attachments (${MAX_ATTACHMENTS.PER_RUN_TEST}) reached for this test in this run`, + {cause: ErrorCause.INVALID_PARAMS}, + ) + } + } + + const storage = await getStorageProvider() + + // Generate storage key + const storageKey = generateStorageKey({ + projectId: params.projectId, + testId: params.testId, + runId: params.runId, + filename: params.filename, + attachmentType: params.attachmentType, + }) + + // Get presigned URL + const result = await storage.getPresignedUploadUrl( + storageKey, + params.mimeType, + 3600, // 1 hour expiry + ) + + return { + uploadUrl: result.uploadUrl, + storageKey: result.key, + expiresAt: result.expiresAt, + } + }, + + /** + * Confirm upload after client has uploaded to storage + */ + async confirmUpload(params: IConfirmUpload): Promise { + const storage = await getStorageProvider() + const mediaType = getMediaTypeFromMime(params.mimeType)! + + // Verify file exists in storage + const exists = await storage.exists(params.storageKey) + if (!exists) { + throw new Error('File not found in storage. Please upload the file first.', { + cause: ErrorCause.INVALID_PARAMS, + }) + } + + if (params.attachmentType === AttachmentType.EXPECTED) { + // Create test attachment record + const {attachmentId} = await TestAttachmentsDao.createAttachment({ + testId: params.testId, + projectId: params.projectId, + storageKey: params.storageKey, + originalFilename: params.filename, + mimeType: params.mimeType, + fileSize: params.fileSize, + mediaType, + description: params.description, + createdBy: params.createdBy, + }) + + const attachment = await TestAttachmentsDao.getAttachmentById(attachmentId) + if (!attachment) { + throw new Error('Failed to retrieve created attachment') + } + + const url = await storage.getSignedUrl(params.storageKey) + + return { + attachmentId: attachment.attachmentId, + testId: attachment.testId, + projectId: attachment.projectId, + storageKey: attachment.storageKey, + originalFilename: attachment.originalFilename, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + mediaType: attachment.mediaType, + description: attachment.description, + displayOrder: attachment.displayOrder, + url, + createdOn: attachment.createdOn, + } + } else { + // Create run attachment record + if (!params.runId) { + throw new Error('runId is required for actual behavior attachments', { + cause: ErrorCause.INVALID_PARAMS, + }) + } + + const {attachmentId} = await RunAttachmentsDao.createAttachment({ + runId: params.runId, + testId: params.testId, + testRunMapId: params.testRunMapId, + projectId: params.projectId, + storageKey: params.storageKey, + originalFilename: params.filename, + mimeType: params.mimeType, + fileSize: params.fileSize, + mediaType, + description: params.description, + createdBy: params.createdBy, + }) + + const attachment = await RunAttachmentsDao.getAttachmentById(attachmentId) + if (!attachment) { + throw new Error('Failed to retrieve created attachment') + } + + const url = await storage.getSignedUrl(params.storageKey) + + return { + attachmentId: attachment.attachmentId, + testId: attachment.testId, + projectId: attachment.projectId, + runId: attachment.runId, + storageKey: attachment.storageKey, + originalFilename: attachment.originalFilename, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + mediaType: attachment.mediaType, + description: attachment.description, + displayOrder: attachment.displayOrder, + url, + createdOn: attachment.createdOn, + } + } + }, + + /** + * Get test attachments (expected behavior) + */ + async getTestAttachments(params: IGetAttachments): Promise { + const attachments = await TestAttachmentsDao.getAttachments({ + testId: params.testId, + projectId: params.projectId, + }) + + const storage = await getStorageProvider() + + // Generate signed URLs for all attachments + const results: AttachmentResponse[] = await Promise.all( + attachments.map(async (attachment) => { + const url = await storage.getSignedUrl(attachment.storageKey) + return { + attachmentId: attachment.attachmentId, + testId: attachment.testId, + projectId: attachment.projectId, + storageKey: attachment.storageKey, + originalFilename: attachment.originalFilename, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + mediaType: attachment.mediaType, + description: attachment.description, + displayOrder: attachment.displayOrder, + url, + createdOn: attachment.createdOn, + } + }), + ) + + return results + }, + + /** + * Get run attachments (actual behavior) + */ + async getRunAttachments(params: IGetAttachments): Promise { + if (!params.runId) { + throw new Error('runId is required', {cause: ErrorCause.INVALID_PARAMS}) + } + + const attachments = await RunAttachmentsDao.getAttachments({ + runId: params.runId, + testId: params.testId, + projectId: params.projectId, + }) + + const storage = await getStorageProvider() + + // Generate signed URLs for all attachments + const results: AttachmentResponse[] = await Promise.all( + attachments.map(async (attachment) => { + const url = await storage.getSignedUrl(attachment.storageKey) + return { + attachmentId: attachment.attachmentId, + testId: attachment.testId, + projectId: attachment.projectId, + runId: attachment.runId, + storageKey: attachment.storageKey, + originalFilename: attachment.originalFilename, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + mediaType: attachment.mediaType, + description: attachment.description, + displayOrder: attachment.displayOrder, + url, + createdOn: attachment.createdOn, + } + }), + ) + + return results + }, + + /** + * Get all attachments for a test in a run (both expected and actual) + */ + async getAllAttachmentsForRunTest(params: IGetAttachments): Promise<{ + expected: AttachmentResponse[] + actual: AttachmentResponse[] + }> { + const [expected, actual] = await Promise.all([ + this.getTestAttachments(params), + params.runId + ? this.getRunAttachments(params) + : Promise.resolve([] as AttachmentResponse[]), + ]) + + return {expected, actual} + }, + + /** + * Delete an attachment + */ + async deleteAttachment(params: IDeleteAttachment): Promise<{deleted: boolean}> { + const storage = await getStorageProvider() + + let result: {deleted: boolean; storageKey: string | null} + + if (params.attachmentType === AttachmentType.EXPECTED) { + result = await TestAttachmentsDao.deleteAttachment({ + attachmentId: params.attachmentId, + projectId: params.projectId, + }) + } else { + result = await RunAttachmentsDao.deleteAttachment({ + attachmentId: params.attachmentId, + projectId: params.projectId, + }) + } + + if (result.deleted && result.storageKey) { + // Delete from storage + try { + await storage.delete(result.storageKey) + logger({ + type: LogType.INFO, + tag: 'AttachmentsController', + message: `Deleted attachment ${params.attachmentId} from storage`, + }) + } catch (error: any) { + // Log error but don't fail - the DB record is already deleted + logger({ + type: LogType.ERROR, + tag: 'AttachmentsController', + message: `Failed to delete file from storage: ${error.message}`, + }) + } + } + + return {deleted: result.deleted} + }, +} + +export default AttachmentsController + diff --git a/app/db/dao/runAttachments.dao.ts b/app/db/dao/runAttachments.dao.ts new file mode 100644 index 0000000..0cc9814 --- /dev/null +++ b/app/db/dao/runAttachments.dao.ts @@ -0,0 +1,362 @@ +/** + * Run Attachments DAO + * + * Data Access Object for run attachment operations. + * Handles CRUD operations for attachments associated with test executions in runs. + */ + +import {and, eq, inArray, asc} from 'drizzle-orm' +import {dbClient} from '../client' +import { + runAttachments, + RunAttachment, + NewRunAttachment, +} from '@schema/attachments' +import {logger, LogType} from '~/utils/logger' +import {errorHandling} from './utils' + +export interface ICreateRunAttachment { + runId: number + testId: number + testRunMapId?: number + projectId: number + storageKey: string + originalFilename: string + mimeType: string + fileSize: number + mediaType: 'image' | 'video' + description?: string + displayOrder?: number + createdBy: number +} + +export interface IGetRunAttachments { + runId: number + testId: number + projectId: number +} + +export interface IDeleteRunAttachment { + attachmentId: number + projectId: number +} + +export interface IUpdateRunAttachment { + attachmentId: number + description?: string + displayOrder?: number +} + +const RunAttachmentsDao = { + /** + * Create a new run attachment + */ + async createAttachment(params: ICreateRunAttachment): Promise<{ + attachmentId: number + }> { + try { + const result = await dbClient.insert(runAttachments).values({ + runId: params.runId, + testId: params.testId, + testRunMapId: params.testRunMapId, + projectId: params.projectId, + storageKey: params.storageKey, + originalFilename: params.originalFilename, + mimeType: params.mimeType, + fileSize: params.fileSize, + mediaType: params.mediaType, + description: params.description, + displayOrder: params.displayOrder ?? 0, + createdBy: params.createdBy, + }) + + logger({ + type: LogType.INFO, + tag: 'RunAttachmentsDao', + message: `Created attachment for run ${params.runId}, test ${params.testId}`, + }) + + return {attachmentId: result[0].insertId} + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to create attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Get all attachments for a test within a run + */ + async getAttachments(params: IGetRunAttachments): Promise { + try { + const result = await dbClient + .select() + .from(runAttachments) + .where( + and( + eq(runAttachments.runId, params.runId), + eq(runAttachments.testId, params.testId), + eq(runAttachments.projectId, params.projectId), + ), + ) + .orderBy(asc(runAttachments.displayOrder), asc(runAttachments.createdOn)) + + return result + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to get attachments: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Get a single attachment by ID + */ + async getAttachmentById(attachmentId: number): Promise { + try { + const result = await dbClient + .select() + .from(runAttachments) + .where(eq(runAttachments.attachmentId, attachmentId)) + .limit(1) + + return result[0] || null + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to get attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Delete an attachment + */ + async deleteAttachment(params: IDeleteRunAttachment): Promise<{ + deleted: boolean + storageKey: string | null + }> { + try { + // First, get the attachment to retrieve the storage key + const attachment = await dbClient + .select({storageKey: runAttachments.storageKey}) + .from(runAttachments) + .where( + and( + eq(runAttachments.attachmentId, params.attachmentId), + eq(runAttachments.projectId, params.projectId), + ), + ) + .limit(1) + + if (!attachment[0]) { + return {deleted: false, storageKey: null} + } + + const storageKey = attachment[0].storageKey + + // Delete the record + const result = await dbClient + .delete(runAttachments) + .where( + and( + eq(runAttachments.attachmentId, params.attachmentId), + eq(runAttachments.projectId, params.projectId), + ), + ) + + logger({ + type: LogType.INFO, + tag: 'RunAttachmentsDao', + message: `Deleted attachment ${params.attachmentId}`, + }) + + return { + deleted: result[0].affectedRows > 0, + storageKey, + } + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to delete attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Update attachment metadata + */ + async updateAttachment(params: IUpdateRunAttachment): Promise<{ + updated: boolean + }> { + try { + const updateData: Partial = {} + + if (params.description !== undefined) { + updateData.description = params.description + } + if (params.displayOrder !== undefined) { + updateData.displayOrder = params.displayOrder + } + + if (Object.keys(updateData).length === 0) { + return {updated: false} + } + + const result = await dbClient + .update(runAttachments) + .set(updateData) + .where(eq(runAttachments.attachmentId, params.attachmentId)) + + return {updated: result[0].affectedRows > 0} + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to update attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Count attachments for a test within a run + */ + async countAttachments(runId: number, testId: number): Promise { + try { + const result = await dbClient + .select() + .from(runAttachments) + .where( + and( + eq(runAttachments.runId, runId), + eq(runAttachments.testId, testId), + ), + ) + + return result.length + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to count attachments: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Get all attachments for a run + */ + async getAttachmentsForRun(runId: number): Promise { + try { + const result = await dbClient + .select() + .from(runAttachments) + .where(eq(runAttachments.runId, runId)) + .orderBy(asc(runAttachments.testId), asc(runAttachments.displayOrder)) + + return result + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to get attachments for run: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Delete all attachments for a run (used when deleting a run) + * Returns all storage keys for cleanup + */ + async deleteAllAttachmentsForRun(runId: number): Promise { + try { + // Get all storage keys first + const attachments = await dbClient + .select({storageKey: runAttachments.storageKey}) + .from(runAttachments) + .where(eq(runAttachments.runId, runId)) + + const storageKeys = attachments.map((a) => a.storageKey) + + if (storageKeys.length > 0) { + await dbClient + .delete(runAttachments) + .where(eq(runAttachments.runId, runId)) + } + + logger({ + type: LogType.INFO, + tag: 'RunAttachmentsDao', + message: `Deleted ${storageKeys.length} attachments for run ${runId}`, + }) + + return storageKeys + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to delete attachments for run: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Get attachments for multiple tests in a run (bulk fetch) + */ + async getAttachmentsForTestsInRun( + runId: number, + testIds: number[], + ): Promise { + try { + if (testIds.length === 0) { + return [] + } + + const result = await dbClient + .select() + .from(runAttachments) + .where( + and( + eq(runAttachments.runId, runId), + inArray(runAttachments.testId, testIds), + ), + ) + .orderBy(asc(runAttachments.testId), asc(runAttachments.displayOrder)) + + return result + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'RunAttachmentsDao', + message: `Failed to get attachments for tests in run: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, +} + +export default RunAttachmentsDao + diff --git a/app/db/dao/testAttachments.dao.ts b/app/db/dao/testAttachments.dao.ts new file mode 100644 index 0000000..d68d52c --- /dev/null +++ b/app/db/dao/testAttachments.dao.ts @@ -0,0 +1,326 @@ +/** + * Test Attachments DAO + * + * Data Access Object for test attachment operations. + * Handles CRUD operations for attachments associated with test definitions. + */ + +import {and, eq, inArray, asc} from 'drizzle-orm' +import {dbClient} from '../client' +import { + testAttachments, + TestAttachment, + NewTestAttachment, +} from '@schema/attachments' +import {logger, LogType} from '~/utils/logger' +import {errorHandling} from './utils' + +export interface ICreateTestAttachment { + testId: number + projectId: number + storageKey: string + originalFilename: string + mimeType: string + fileSize: number + mediaType: 'image' | 'video' + description?: string + displayOrder?: number + createdBy: number +} + +export interface IGetTestAttachments { + testId: number + projectId: number +} + +export interface IDeleteTestAttachment { + attachmentId: number + projectId: number +} + +export interface IUpdateTestAttachment { + attachmentId: number + description?: string + displayOrder?: number +} + +const TestAttachmentsDao = { + /** + * Create a new test attachment + */ + async createAttachment(params: ICreateTestAttachment): Promise<{ + attachmentId: number + }> { + try { + const result = await dbClient.insert(testAttachments).values({ + testId: params.testId, + projectId: params.projectId, + storageKey: params.storageKey, + originalFilename: params.originalFilename, + mimeType: params.mimeType, + fileSize: params.fileSize, + mediaType: params.mediaType, + description: params.description, + displayOrder: params.displayOrder ?? 0, + createdBy: params.createdBy, + }) + + logger({ + type: LogType.INFO, + tag: 'TestAttachmentsDao', + message: `Created attachment for test ${params.testId}`, + }) + + return {attachmentId: result[0].insertId} + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to create attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Get all attachments for a test + */ + async getAttachments( + params: IGetTestAttachments, + ): Promise { + try { + const result = await dbClient + .select() + .from(testAttachments) + .where( + and( + eq(testAttachments.testId, params.testId), + eq(testAttachments.projectId, params.projectId), + ), + ) + .orderBy(asc(testAttachments.displayOrder), asc(testAttachments.createdOn)) + + return result + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to get attachments: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Get a single attachment by ID + */ + async getAttachmentById( + attachmentId: number, + ): Promise { + try { + const result = await dbClient + .select() + .from(testAttachments) + .where(eq(testAttachments.attachmentId, attachmentId)) + .limit(1) + + return result[0] || null + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to get attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Delete an attachment + */ + async deleteAttachment(params: IDeleteTestAttachment): Promise<{ + deleted: boolean + storageKey: string | null + }> { + try { + // First, get the attachment to retrieve the storage key + const attachment = await dbClient + .select({storageKey: testAttachments.storageKey}) + .from(testAttachments) + .where( + and( + eq(testAttachments.attachmentId, params.attachmentId), + eq(testAttachments.projectId, params.projectId), + ), + ) + .limit(1) + + if (!attachment[0]) { + return {deleted: false, storageKey: null} + } + + const storageKey = attachment[0].storageKey + + // Delete the record + const result = await dbClient + .delete(testAttachments) + .where( + and( + eq(testAttachments.attachmentId, params.attachmentId), + eq(testAttachments.projectId, params.projectId), + ), + ) + + logger({ + type: LogType.INFO, + tag: 'TestAttachmentsDao', + message: `Deleted attachment ${params.attachmentId}`, + }) + + return { + deleted: result[0].affectedRows > 0, + storageKey, + } + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to delete attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Update attachment metadata + */ + async updateAttachment(params: IUpdateTestAttachment): Promise<{ + updated: boolean + }> { + try { + const updateData: Partial = {} + + if (params.description !== undefined) { + updateData.description = params.description + } + if (params.displayOrder !== undefined) { + updateData.displayOrder = params.displayOrder + } + + if (Object.keys(updateData).length === 0) { + return {updated: false} + } + + const result = await dbClient + .update(testAttachments) + .set(updateData) + .where(eq(testAttachments.attachmentId, params.attachmentId)) + + return {updated: result[0].affectedRows > 0} + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to update attachment: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Count attachments for a test + */ + async countAttachments(testId: number): Promise { + try { + const result = await dbClient + .select() + .from(testAttachments) + .where(eq(testAttachments.testId, testId)) + + return result.length + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to count attachments: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Get attachments for multiple tests (bulk fetch) + */ + async getAttachmentsForTests( + testIds: number[], + ): Promise { + try { + if (testIds.length === 0) { + return [] + } + + const result = await dbClient + .select() + .from(testAttachments) + .where(inArray(testAttachments.testId, testIds)) + .orderBy(asc(testAttachments.testId), asc(testAttachments.displayOrder)) + + return result + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to get attachments for tests: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, + + /** + * Delete all attachments for a test (used when deleting a test) + * Returns all storage keys for cleanup + */ + async deleteAllAttachmentsForTest(testId: number): Promise { + try { + // Get all storage keys first + const attachments = await dbClient + .select({storageKey: testAttachments.storageKey}) + .from(testAttachments) + .where(eq(testAttachments.testId, testId)) + + const storageKeys = attachments.map((a) => a.storageKey) + + if (storageKeys.length > 0) { + await dbClient + .delete(testAttachments) + .where(eq(testAttachments.testId, testId)) + } + + logger({ + type: LogType.INFO, + tag: 'TestAttachmentsDao', + message: `Deleted ${storageKeys.length} attachments for test ${testId}`, + }) + + return storageKeys + } catch (error: any) { + logger({ + type: LogType.SQL_ERROR, + tag: 'TestAttachmentsDao', + message: `Failed to delete attachments for test: ${error.message}`, + }) + errorHandling(error) + throw error + } + }, +} + +export default TestAttachmentsDao + diff --git a/app/db/schema/attachments.ts b/app/db/schema/attachments.ts new file mode 100644 index 0000000..7458a0f --- /dev/null +++ b/app/db/schema/attachments.ts @@ -0,0 +1,142 @@ +/** + * Attachments Schema + * + * Database schema for test and run attachments. + * Supports two types of attachments: + * 1. Test Attachments - Expected behavior media attached to test definitions + * 2. Run Attachments - Actual behavior media attached during test execution + */ + +import {sql} from 'drizzle-orm' +import { + index, + int, + mysqlEnum, + mysqlTable, + timestamp, + varchar, + text, +} from 'drizzle-orm/mysql-core' +import {projects} from './projects' +import {tests} from './tests' +import {runs, testRunMap} from './runs' +import {users} from './users' + +/** + * Test Attachments Table + * + * Stores attachments that represent expected behavior for test cases. + * These attachments are visible in all runs that include the test. + */ +export const testAttachments = mysqlTable( + 'testAttachments', + { + attachmentId: int('attachmentId').primaryKey().autoincrement(), + testId: int('testId') + .references(() => tests.testId, {onDelete: 'cascade'}) + .notNull(), + projectId: int('projectId') + .references(() => projects.projectId, {onDelete: 'cascade'}) + .notNull(), + + // File metadata + storageKey: varchar('storageKey', {length: 500}).notNull(), + originalFilename: varchar('originalFilename', {length: 255}).notNull(), + mimeType: varchar('mimeType', {length: 100}).notNull(), + fileSize: int('fileSize').notNull(), // Size in bytes + mediaType: mysqlEnum('mediaType', ['image', 'video']).notNull(), + + // Optional metadata + description: text('description'), + displayOrder: int('displayOrder').default(0), + + // Audit fields + createdBy: int('createdBy').references(() => users.userId, { + onDelete: 'set null', + }), + createdOn: timestamp('createdOn') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedOn: timestamp('updatedOn') + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .onUpdateNow(), + }, + (table) => { + return { + testAttachmentsTestIdIndex: index('testAttachmentsTestIdIndex').on( + table.testId, + ), + testAttachmentsProjectIdIndex: index('testAttachmentsProjectIdIndex').on( + table.projectId, + ), + } + }, +) + +/** + * Run Attachments Table + * + * Stores attachments that represent actual behavior observed during test execution. + * These attachments are specific to a test within a particular run. + */ +export const runAttachments = mysqlTable( + 'runAttachments', + { + attachmentId: int('attachmentId').primaryKey().autoincrement(), + runId: int('runId') + .references(() => runs.runId, {onDelete: 'cascade'}) + .notNull(), + testId: int('testId') + .references(() => tests.testId, {onDelete: 'cascade'}) + .notNull(), + testRunMapId: int('testRunMapId').references( + () => testRunMap.testRunMapId, + {onDelete: 'cascade'}, + ), + projectId: int('projectId') + .references(() => projects.projectId, {onDelete: 'cascade'}) + .notNull(), + + // File metadata + storageKey: varchar('storageKey', {length: 500}).notNull(), + originalFilename: varchar('originalFilename', {length: 255}).notNull(), + mimeType: varchar('mimeType', {length: 100}).notNull(), + fileSize: int('fileSize').notNull(), // Size in bytes + mediaType: mysqlEnum('mediaType', ['image', 'video']).notNull(), + + // Optional metadata + description: text('description'), + displayOrder: int('displayOrder').default(0), + + // Audit fields + createdBy: int('createdBy').references(() => users.userId, { + onDelete: 'set null', + }), + createdOn: timestamp('createdOn') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedOn: timestamp('updatedOn') + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + .onUpdateNow(), + }, + (table) => { + return { + runAttachmentsRunTestIndex: index('runAttachmentsRunTestIndex').on( + table.runId, + table.testId, + ), + runAttachmentsProjectIdIndex: index('runAttachmentsProjectIdIndex').on( + table.projectId, + ), + } + }, +) + +// Type exports for use in DAOs and controllers +export type TestAttachment = typeof testAttachments.$inferSelect +export type NewTestAttachment = typeof testAttachments.$inferInsert +export type RunAttachment = typeof runAttachments.$inferSelect +export type NewRunAttachment = typeof runAttachments.$inferInsert + diff --git a/app/routes/api.v1.attachments.serve.$.ts b/app/routes/api.v1.attachments.serve.$.ts new file mode 100644 index 0000000..b43af1e --- /dev/null +++ b/app/routes/api.v1.attachments.serve.$.ts @@ -0,0 +1,80 @@ +/** + * Serve Attachment Route (Splat Route) + * + * Serves uploaded attachment files from local storage. + * The $ makes this a splat route that captures the rest of the path. + * + * GET /api/v1/attachments/serve/* + */ + +import {LoaderFunctionArgs} from '@remix-run/node' +import * as fs from 'fs' +import * as path from 'path' + +// Mime type mapping +const MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', +} + +function getMimeType(filename: string): string { + const ext = path.extname(filename).toLowerCase() + return MIME_TYPES[ext] || 'application/octet-stream' +} + +export const loader = async ({request, params}: LoaderFunctionArgs) => { + try { + // Get the splat parameter (everything after /api/v1/attachments/serve/) + const filePath = params['*'] + + if (!filePath) { + return new Response('Not found', {status: 404}) + } + + // Remove any query parameters from the path + const cleanPath = decodeURIComponent(filePath.split('?')[0]) + + // Build the full path to the file + const basePath = process.env.STORAGE_LOCAL_PATH || path.join(process.cwd(), 'uploads', 'attachments') + const fullPath = path.join(basePath, cleanPath) + + // Security: Ensure the resolved path is within the uploads directory + const resolvedPath = path.resolve(fullPath) + const resolvedBasePath = path.resolve(basePath) + if (!resolvedPath.startsWith(resolvedBasePath)) { + console.error('Path traversal attempt detected:', cleanPath) + return new Response('Forbidden', {status: 403}) + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + console.error('File not found:', resolvedPath) + return new Response('File not found', {status: 404}) + } + + // Read the file + const fileBuffer = fs.readFileSync(resolvedPath) + const mimeType = getMimeType(resolvedPath) + + // Return the file with appropriate headers + return new Response(fileBuffer, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Content-Length': String(fileBuffer.length), + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Disposition': 'inline', + }, + }) + } catch (error: any) { + console.error('Error serving attachment:', error) + return new Response('Internal server error', {status: 500}) + } +} + diff --git a/app/routes/api/v1/confirmAttachmentUpload.ts b/app/routes/api/v1/confirmAttachmentUpload.ts new file mode 100644 index 0000000..b19bbf1 --- /dev/null +++ b/app/routes/api/v1/confirmAttachmentUpload.ts @@ -0,0 +1,85 @@ +/** + * Confirm Attachment Upload API + * + * Confirms an upload after the client has uploaded to storage via presigned URL. + * Creates the database record for the attachment. + * + * POST /api/v1/attachments/confirm-upload + * Body: { projectId, testId, runId?, storageKey, filename, mimeType, fileSize, description?, attachmentType } + */ + +import {ActionFunctionArgs} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {AttachmentType} from '~/services/storage' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' +import {getRequestParams} from '~/routes/utilities/utils' + +const ConfirmAttachmentUploadSchema = z.object({ + projectId: z.number().int().positive(), + testId: z.number().int().positive(), + runId: z.number().int().positive().optional(), + testRunMapId: z.number().int().positive().optional(), + storageKey: z.string().min(1), + filename: z.string().min(1), + mimeType: z.string().min(1), + fileSize: z.number().int().positive(), + description: z.string().optional(), + attachmentType: z.enum(['expected', 'actual']), +}) + +export type ConfirmAttachmentUploadRequest = z.infer< + typeof ConfirmAttachmentUploadSchema +> + +export const action = async ({request}: ActionFunctionArgs) => { + try { + const user = await getUserAndCheckAccess({ + request, + resource: API.ConfirmAttachmentUpload, + }) + + const data = await getRequestParams( + request, + ConfirmAttachmentUploadSchema, + ) + + // Validate that runId is provided for actual attachments + if (data.attachmentType === 'actual' && !data.runId) { + return responseHandler({ + error: 'runId is required for actual behavior attachments', + status: 400, + }) + } + + const result = await AttachmentsController.confirmUpload({ + projectId: data.projectId, + testId: data.testId, + runId: data.runId, + testRunMapId: data.testRunMapId, + storageKey: data.storageKey, + filename: data.filename, + mimeType: data.mimeType, + fileSize: data.fileSize, + description: data.description, + createdBy: user?.userId ?? 0, + attachmentType: + data.attachmentType === 'expected' + ? AttachmentType.EXPECTED + : AttachmentType.ACTUAL, + }) + + return responseHandler({ + data: result, + status: 201, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} + diff --git a/app/routes/api/v1/deleteRunAttachment.ts b/app/routes/api/v1/deleteRunAttachment.ts new file mode 100644 index 0000000..b9b55ad --- /dev/null +++ b/app/routes/api/v1/deleteRunAttachment.ts @@ -0,0 +1,62 @@ +/** + * Delete Run Attachment API + * + * Deletes an attachment from a test within a run. + * + * DELETE /api/v1/run/attachments/delete + * Body: { attachmentId, projectId } + */ + +import {ActionFunctionArgs} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {AttachmentType} from '~/services/storage' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' +import {getRequestParams} from '~/routes/utilities/utils' + +const DeleteRunAttachmentSchema = z.object({ + attachmentId: z.number().int().positive(), + projectId: z.number().int().positive(), +}) + +export type DeleteRunAttachmentRequest = z.infer + +export const action = async ({request}: ActionFunctionArgs) => { + try { + await getUserAndCheckAccess({ + request, + resource: API.DeleteRunAttachment, + }) + + const data = await getRequestParams( + request, + DeleteRunAttachmentSchema, + ) + + const result = await AttachmentsController.deleteAttachment({ + attachmentId: data.attachmentId, + projectId: data.projectId, + attachmentType: AttachmentType.ACTUAL, + }) + + if (!result.deleted) { + return responseHandler({ + error: 'Attachment not found or already deleted', + status: 404, + }) + } + + return responseHandler({ + data: {message: 'Attachment deleted successfully'}, + status: 200, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} + diff --git a/app/routes/api/v1/deleteTestAttachment.ts b/app/routes/api/v1/deleteTestAttachment.ts new file mode 100644 index 0000000..99f9139 --- /dev/null +++ b/app/routes/api/v1/deleteTestAttachment.ts @@ -0,0 +1,62 @@ +/** + * Delete Test Attachment API + * + * Deletes an attachment from a test case. + * + * DELETE /api/v1/test/attachments/delete + * Body: { attachmentId, projectId } + */ + +import {ActionFunctionArgs} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {AttachmentType} from '~/services/storage' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' +import {getRequestParams} from '~/routes/utilities/utils' + +const DeleteTestAttachmentSchema = z.object({ + attachmentId: z.number().int().positive(), + projectId: z.number().int().positive(), +}) + +export type DeleteTestAttachmentRequest = z.infer + +export const action = async ({request}: ActionFunctionArgs) => { + try { + await getUserAndCheckAccess({ + request, + resource: API.DeleteTestAttachment, + }) + + const data = await getRequestParams( + request, + DeleteTestAttachmentSchema, + ) + + const result = await AttachmentsController.deleteAttachment({ + attachmentId: data.attachmentId, + projectId: data.projectId, + attachmentType: AttachmentType.EXPECTED, + }) + + if (!result.deleted) { + return responseHandler({ + error: 'Attachment not found or already deleted', + status: 404, + }) + } + + return responseHandler({ + data: {message: 'Attachment deleted successfully'}, + status: 200, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} + diff --git a/app/routes/api/v1/presignedUploadUrl.ts b/app/routes/api/v1/presignedUploadUrl.ts new file mode 100644 index 0000000..75b1045 --- /dev/null +++ b/app/routes/api/v1/presignedUploadUrl.ts @@ -0,0 +1,79 @@ +/** + * Get Presigned Upload URL API + * + * Generates a presigned URL for direct client-side upload to storage. + * This allows clients to upload files directly to the storage provider + * without going through the server. + * + * POST /api/v1/attachments/presigned-url + * Body: { projectId, testId, runId?, filename, mimeType, fileSize, attachmentType } + */ + +import {ActionFunctionArgs} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {AttachmentType} from '~/services/storage' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' +import {getRequestParams} from '~/routes/utilities/utils' + +const GetPresignedUploadUrlSchema = z.object({ + projectId: z.number().int().positive(), + testId: z.number().int().positive(), + runId: z.number().int().positive().optional(), + filename: z.string().min(1), + mimeType: z.string().min(1), + fileSize: z.number().int().positive(), + attachmentType: z.enum(['expected', 'actual']), +}) + +export type GetPresignedUploadUrlRequest = z.infer< + typeof GetPresignedUploadUrlSchema +> + +export const action = async ({request}: ActionFunctionArgs) => { + try { + await getUserAndCheckAccess({ + request, + resource: API.GetPresignedUploadUrl, + }) + + const data = await getRequestParams( + request, + GetPresignedUploadUrlSchema, + ) + + // Validate that runId is provided for actual attachments + if (data.attachmentType === 'actual' && !data.runId) { + return responseHandler({ + error: 'runId is required for actual behavior attachments', + status: 400, + }) + } + + const result = await AttachmentsController.getPresignedUploadUrl({ + projectId: data.projectId, + testId: data.testId, + runId: data.runId, + filename: data.filename, + mimeType: data.mimeType, + fileSize: data.fileSize, + attachmentType: + data.attachmentType === 'expected' + ? AttachmentType.EXPECTED + : AttachmentType.ACTUAL, + }) + + return responseHandler({ + data: result, + status: 200, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} + diff --git a/app/routes/api/v1/runAttachments.ts b/app/routes/api/v1/runAttachments.ts new file mode 100644 index 0000000..e6bff14 --- /dev/null +++ b/app/routes/api/v1/runAttachments.ts @@ -0,0 +1,58 @@ +/** + * Get Run Attachments API + * + * Retrieves all attachments for a test within a run. + * Returns both expected (from test definition) and actual (from run execution) attachments. + * + * GET /api/v1/run/attachments + * Query params: projectId, runId, testId + */ + +import {LoaderFunctionArgs} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' + +const GetRunAttachmentsSchema = z.object({ + projectId: z.coerce.number().int().positive(), + runId: z.coerce.number().int().positive(), + testId: z.coerce.number().int().positive(), +}) + +export type GetRunAttachmentsRequest = z.infer + +export const loader = async ({request}: LoaderFunctionArgs) => { + try { + await getUserAndCheckAccess({ + request, + resource: API.GetRunAttachments, + }) + + const url = new URL(request.url) + const params = GetRunAttachmentsSchema.parse({ + projectId: url.searchParams.get('projectId'), + runId: url.searchParams.get('runId'), + testId: url.searchParams.get('testId'), + }) + + // Get both expected (test-level) and actual (run-level) attachments + const attachments = await AttachmentsController.getAllAttachmentsForRunTest({ + testId: params.testId, + projectId: params.projectId, + runId: params.runId, + }) + + return responseHandler({ + data: attachments, + status: 200, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} + diff --git a/app/routes/api/v1/testAttachments.ts b/app/routes/api/v1/testAttachments.ts new file mode 100644 index 0000000..7c21501 --- /dev/null +++ b/app/routes/api/v1/testAttachments.ts @@ -0,0 +1,53 @@ +/** + * Get Test Attachments API + * + * Retrieves all attachments for a specific test (expected behavior). + * + * GET /api/v1/test/attachments + * Query params: projectId, testId + */ + +import {LoaderFunctionArgs} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' + +const GetTestAttachmentsSchema = z.object({ + projectId: z.coerce.number().int().positive(), + testId: z.coerce.number().int().positive(), +}) + +export type GetTestAttachmentsRequest = z.infer + +export const loader = async ({request}: LoaderFunctionArgs) => { + try { + await getUserAndCheckAccess({ + request, + resource: API.GetTestAttachments, + }) + + const url = new URL(request.url) + const params = GetTestAttachmentsSchema.parse({ + projectId: url.searchParams.get('projectId'), + testId: url.searchParams.get('testId'), + }) + + const attachments = await AttachmentsController.getTestAttachments({ + testId: params.testId, + projectId: params.projectId, + }) + + return responseHandler({ + data: attachments, + status: 200, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} + diff --git a/app/routes/api/v1/uploadRunAttachment.ts b/app/routes/api/v1/uploadRunAttachment.ts new file mode 100644 index 0000000..f1ed89c --- /dev/null +++ b/app/routes/api/v1/uploadRunAttachment.ts @@ -0,0 +1,179 @@ +/** + * Upload Run Attachment API + * + * Uploads an attachment for a test within a run (actual behavior). + * Supports both direct upload and presigned URL confirmation. + * + * POST /api/v1/run/attachments/upload?projectId=X&runId=Y&testId=Z + * Body: multipart/form-data with file, or JSON with storageKey for presigned URL confirmation + */ + +import {ActionFunctionArgs, unstable_parseMultipartFormData} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' +import {getRequestParams} from '~/routes/utilities/utils' +import {AttachmentType} from '~/services/storage' + +// For JSON body (presigned URL confirmation) +const ConfirmUploadSchema = z.object({ + projectId: z.number().int().positive(), + runId: z.number().int().positive(), + testId: z.number().int().positive(), + testRunMapId: z.number().int().positive().optional(), + storageKey: z.string().min(1), + filename: z.string().min(1), + mimeType: z.string().min(1), + fileSize: z.number().int().positive(), + description: z.string().optional(), +}) + +export type ConfirmRunUploadRequest = z.infer + +// Mime type detection from file extension +const MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', +} + +function getMimeType(filename: string, providedType?: string): string { + // Use provided type if it's valid + if (providedType && providedType !== 'application/octet-stream') { + return providedType + } + + // Fallback to extension-based detection + const ext = filename.toLowerCase().substring(filename.lastIndexOf('.')) + return MIME_TYPES[ext] || 'application/octet-stream' +} + +// Store for file data during upload processing +interface FileUploadData { + filename: string + buffer: Buffer + contentType: string + size: number +} + +export const action = async ({request}: ActionFunctionArgs) => { + try { + const user = await getUserAndCheckAccess({ + request, + resource: API.UploadRunAttachment, + }) + + const contentType = request.headers.get('content-type') || '' + const url = new URL(request.url) + + // Handle multipart form data (direct file upload) + if (contentType.includes('multipart/form-data')) { + // Get IDs from URL query params + const projectId = Number(url.searchParams.get('projectId')) + const runId = Number(url.searchParams.get('runId')) + const testId = Number(url.searchParams.get('testId')) + const testRunMapId = url.searchParams.get('testRunMapId') + ? Number(url.searchParams.get('testRunMapId')) + : undefined + const description = url.searchParams.get('description') || undefined + + if (!projectId || !runId || !testId || isNaN(projectId) || isNaN(runId) || isNaN(testId)) { + return responseHandler({ + error: 'projectId, runId, and testId are required as query parameters', + status: 400, + }) + } + + // Store file data during parsing + const fileDataStore: {data: FileUploadData | null} = {data: null} + + await unstable_parseMultipartFormData( + request, + async ({filename, data, contentType: fileContentType}) => { + if (!filename) return null + + // Collect file chunks + const chunks: Uint8Array[] = [] + for await (const chunk of data) { + chunks.push(chunk) + } + const buffer = Buffer.concat(chunks) + + // Detect mime type from extension if not provided + const mimeType = getMimeType(filename, fileContentType) + + // Store for later use + fileDataStore.data = { + filename, + buffer, + contentType: mimeType, + size: buffer.length, + } + + // Return filename as string (required by Remix) + return filename + }, + ) + + const fileData = fileDataStore.data + if (!fileData) { + return responseHandler({error: 'No file provided', status: 400}) + } + + const result = await AttachmentsController.uploadRunAttachment({ + runId, + testId, + testRunMapId, + projectId, + file: fileData.buffer, + filename: fileData.filename, + mimeType: fileData.contentType, + fileSize: fileData.size, + description, + createdBy: user?.userId ?? 0, + }) + + return responseHandler({ + data: result, + status: 201, + }) + } + + // Handle JSON body (presigned URL confirmation) + const data = await getRequestParams( + request, + ConfirmUploadSchema, + ) + + const result = await AttachmentsController.confirmUpload({ + testId: data.testId, + projectId: data.projectId, + runId: data.runId, + testRunMapId: data.testRunMapId, + storageKey: data.storageKey, + filename: data.filename, + mimeType: data.mimeType, + fileSize: data.fileSize, + description: data.description, + createdBy: user?.userId ?? 0, + attachmentType: AttachmentType.ACTUAL, + }) + + return responseHandler({ + data: result, + status: 201, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} diff --git a/app/routes/api/v1/uploadTestAttachment.ts b/app/routes/api/v1/uploadTestAttachment.ts new file mode 100644 index 0000000..4065998 --- /dev/null +++ b/app/routes/api/v1/uploadTestAttachment.ts @@ -0,0 +1,169 @@ +/** + * Upload Test Attachment API + * + * Uploads an attachment for a test case (expected behavior). + * Supports both direct upload and presigned URL confirmation. + * + * POST /api/v1/test/attachments/upload?projectId=X&testId=Y + * Body: multipart/form-data with file, or JSON with storageKey for presigned URL confirmation + */ + +import {ActionFunctionArgs, unstable_parseMultipartFormData} from '@remix-run/node' +import {z} from 'zod' +import AttachmentsController from '@controllers/attachments.controller' +import {API} from '~/routes/utilities/api' +import {getUserAndCheckAccess} from '~/routes/utilities/checkForUserAndAccess' +import { + errorResponseHandler, + responseHandler, +} from '~/routes/utilities/responseHandler' +import {getRequestParams} from '~/routes/utilities/utils' +import {AttachmentType} from '~/services/storage' + +// For JSON body (presigned URL confirmation) +const ConfirmUploadSchema = z.object({ + projectId: z.number().int().positive(), + testId: z.number().int().positive(), + storageKey: z.string().min(1), + filename: z.string().min(1), + mimeType: z.string().min(1), + fileSize: z.number().int().positive(), + description: z.string().optional(), +}) + +export type ConfirmUploadRequest = z.infer + +// Mime type detection from file extension +const MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', +} + +function getMimeType(filename: string, providedType?: string): string { + // Use provided type if it's valid + if (providedType && providedType !== 'application/octet-stream') { + return providedType + } + + // Fallback to extension-based detection + const ext = filename.toLowerCase().substring(filename.lastIndexOf('.')) + return MIME_TYPES[ext] || 'application/octet-stream' +} + +// Store for file data during upload processing +interface FileUploadData { + filename: string + buffer: Buffer + contentType: string + size: number +} + +export const action = async ({request}: ActionFunctionArgs) => { + try { + const user = await getUserAndCheckAccess({ + request, + resource: API.UploadTestAttachment, + }) + + const contentType = request.headers.get('content-type') || '' + const url = new URL(request.url) + + // Handle multipart form data (direct file upload) + if (contentType.includes('multipart/form-data')) { + // Get projectId and testId from URL query params + const projectId = Number(url.searchParams.get('projectId')) + const testId = Number(url.searchParams.get('testId')) + const description = url.searchParams.get('description') || undefined + + if (!projectId || !testId || isNaN(projectId) || isNaN(testId)) { + return responseHandler({ + error: 'projectId and testId are required as query parameters', + status: 400, + }) + } + + // Store file data during parsing + const fileDataStore: {data: FileUploadData | null} = {data: null} + + await unstable_parseMultipartFormData( + request, + async ({filename, data, contentType: fileContentType}) => { + if (!filename) return null + + // Collect file chunks + const chunks: Uint8Array[] = [] + for await (const chunk of data) { + chunks.push(chunk) + } + const buffer = Buffer.concat(chunks) + + // Detect mime type from extension if not provided + const mimeType = getMimeType(filename, fileContentType) + + // Store for later use + fileDataStore.data = { + filename, + buffer, + contentType: mimeType, + size: buffer.length, + } + + // Return filename as string (required by Remix) + return filename + }, + ) + + const fileData = fileDataStore.data + if (!fileData) { + return responseHandler({error: 'No file provided', status: 400}) + } + + const result = await AttachmentsController.uploadTestAttachment({ + testId, + projectId, + file: fileData.buffer, + filename: fileData.filename, + mimeType: fileData.contentType, + fileSize: fileData.size, + description, + createdBy: user?.userId ?? 0, + }) + + return responseHandler({ + data: result, + status: 201, + }) + } + + // Handle JSON body (presigned URL confirmation) + const data = await getRequestParams( + request, + ConfirmUploadSchema, + ) + + const result = await AttachmentsController.confirmUpload({ + testId: data.testId, + projectId: data.projectId, + storageKey: data.storageKey, + filename: data.filename, + mimeType: data.mimeType, + fileSize: data.fileSize, + description: data.description, + createdBy: user?.userId ?? 0, + attachmentType: AttachmentType.EXPECTED, + }) + + return responseHandler({ + data: result, + status: 201, + }) + } catch (error: any) { + return errorResponseHandler(error) + } +} diff --git a/app/routes/utilities/api.ts b/app/routes/utilities/api.ts index 6bf35ad..2be1a64 100644 --- a/app/routes/utilities/api.ts +++ b/app/routes/utilities/api.ts @@ -61,6 +61,15 @@ export enum API { UpdateUserRole = 'api/v1/user/update-role', AddSection = 'api/v1/project/add-section', EditSection = 'api/v1/project/edit-section', + // Attachment APIs + GetTestAttachments = 'api/v1/test/attachments', + UploadTestAttachment = 'api/v1/test/attachments/upload', + DeleteTestAttachment = 'api/v1/test/attachments/delete', + GetRunAttachments = 'api/v1/run/attachments', + UploadRunAttachment = 'api/v1/run/attachments/upload', + DeleteRunAttachment = 'api/v1/run/attachments/delete', + GetPresignedUploadUrl = 'api/v1/attachments/presigned-url', + ConfirmAttachmentUpload = 'api/v1/attachments/confirm-upload', } export const API_RESOLUTION_PATHS = { @@ -113,6 +122,15 @@ export const API_RESOLUTION_PATHS = { [API.UpdateUserRole]: 'routes/api/v1/updateUserType.ts', [API.AddSection]: 'routes/api/v1/addSection.ts', [API.EditSection]: 'routes/api/v1/editSection.ts', + // Attachment APIs + [API.GetTestAttachments]: 'routes/api/v1/testAttachments.ts', + [API.UploadTestAttachment]: 'routes/api/v1/uploadTestAttachment.ts', + [API.DeleteTestAttachment]: 'routes/api/v1/deleteTestAttachment.ts', + [API.GetRunAttachments]: 'routes/api/v1/runAttachments.ts', + [API.UploadRunAttachment]: 'routes/api/v1/uploadRunAttachment.ts', + [API.DeleteRunAttachment]: 'routes/api/v1/deleteRunAttachment.ts', + [API.GetPresignedUploadUrl]: 'routes/api/v1/presignedUploadUrl.ts', + [API.ConfirmAttachmentUpload]: 'routes/api/v1/confirmAttachmentUpload.ts', } export const CLOSED_API = { @@ -171,4 +189,13 @@ export const ApiToTypeMap: { [API.UpdateUserRole]: ApiTypes.PUT, [API.AddSection]: ApiTypes.POST, [API.EditSection]: ApiTypes.PUT, + // Attachment APIs + [API.GetTestAttachments]: ApiTypes.GET, + [API.UploadTestAttachment]: ApiTypes.POST, + [API.DeleteTestAttachment]: ApiTypes.DELETE, + [API.GetRunAttachments]: ApiTypes.GET, + [API.UploadRunAttachment]: ApiTypes.POST, + [API.DeleteRunAttachment]: ApiTypes.DELETE, + [API.GetPresignedUploadUrl]: ApiTypes.POST, + [API.ConfirmAttachmentUpload]: ApiTypes.POST, } diff --git a/app/screens/CreateTest/EditTestPage.tsx b/app/screens/CreateTest/EditTestPage.tsx index 0564ea9..f30a911 100644 --- a/app/screens/CreateTest/EditTestPage.tsx +++ b/app/screens/CreateTest/EditTestPage.tsx @@ -33,6 +33,12 @@ import { squadListPlaceholder, } from './utils' import {getSectionHierarchy} from '@components/SectionList/utils' +import { + MediaUploader, + MediaGallery, + AttachmentSection, + Attachment, +} from '~/components/Attachments' export default function EditTestPage({ source, @@ -70,15 +76,30 @@ export default function EditTestPage({ const testUpdationFetcher = useFetcher() const addTestFetcher = useFetcher() + const attachmentsFetcher = useFetcher<{data: Attachment[]}>() + const deleteAttachmentFetcher = useFetcher() + const [attachments, setAttachments] = useState([]) + const [pendingDeletions, setPendingDeletions] = useState>(new Set()) useEffect(() => { if (source !== 'addTest') { testDetailsFetcher.load( `/${API.GetTestDetails}?projectId=${projectId}&testId=${testId}`, ) + // Load attachments for existing test + attachmentsFetcher.load( + `/${API.GetTestAttachments}?projectId=${projectId}&testId=${testId}`, + ) } }, [projectId, testId]) + // Update attachments when fetcher completes + useEffect(() => { + if (attachmentsFetcher.data?.data) { + setAttachments(attachmentsFetcher.data.data) + } + }, [attachmentsFetcher.data]) + const [formData, setFormData] = useState( source === 'addTest' ? { @@ -234,7 +255,27 @@ export default function EditTestPage({ [setFormData], ) - const handleEditSubmit = useCallback(() => { + const handleEditSubmit = useCallback(async () => { + // First, delete all pending attachments + if (pendingDeletions.size > 0) { + for (const attachmentId of pendingDeletions) { + deleteAttachmentFetcher.submit( + {attachmentId, projectId}, + { + method: 'DELETE', + action: `/${API.DeleteTestAttachment}`, + encType: 'application/json', + }, + ) + } + // Clear pending deletions and update attachments state + setAttachments((prev) => + prev.filter((a) => !pendingDeletions.has(a.attachmentId)), + ) + setPendingDeletions(new Set()) + } + + // Then submit the form update testUpdationFetcher.submit( {...formData, projectId, testId}, { @@ -243,7 +284,7 @@ export default function EditTestPage({ encType: 'application/json', }, ) - }, [formData, projectId, testId]) + }, [formData, projectId, testId, pendingDeletions]) const handleAddSubmit = useCallback( (addAndNext = false) => { @@ -325,6 +366,48 @@ export default function EditTestPage({ navigate(-1) }, []) + // Attachment handlers + const handleAttachmentUploadComplete = useCallback((attachment: Attachment) => { + setAttachments((prev) => [...prev, attachment]) + toast({ + variant: 'success', + description: 'Attachment uploaded successfully', + }) + }, []) + + const handleAttachmentUploadError = useCallback((error: string) => { + toast({ + variant: 'destructive', + description: error, + }) + }, []) + + // Soft delete - mark attachment for deletion (will be deleted on save) + const handleDeleteAttachment = useCallback( + (attachmentId: number) => { + setPendingDeletions((prev) => new Set([...prev, attachmentId])) + toast({ + description: 'Attachment marked for deletion. Click "Update Test" to confirm.', + }) + }, + [], + ) + + // Undo soft delete - restore an attachment + const handleUndoDelete = useCallback( + (attachmentId: number) => { + setPendingDeletions((prev) => { + const newSet = new Set(prev) + newSet.delete(attachmentId) + return newSet + }) + toast({ + description: 'Attachment restored', + }) + }, + [], + ) + return (
{/* Header Section */} @@ -342,6 +425,13 @@ export default function EditTestPage({
+ {/* Unsaved changes indicator */} + {pendingDeletions.size > 0 && ( + + {pendingDeletions.size} unsaved change{pendingDeletions.size !== 1 ? 's' : ''} + + )} + {source === 'addTest' ? ( <>
+ + {/* Attachments Card */} +
+

+ Expected Behavior Attachments +

+ + {source === 'addTest' ? ( + // Show info message when creating a new test +
+
+ + + +
+

+ Attachments available after creation +

+

+ Create the test case first, then edit it to add screenshots or videos + showing the expected behavior. These attachments will be visible in all test runs. +

+
+
+
+ ) : ( + // Show upload interface when editing an existing test + <> +

+ Upload screenshots or videos showing the expected behavior for this test case. + These attachments will be visible in all test runs. +

+ + {/* Attachment Gallery */} + {attachments.length > 0 && ( +
+ +
+ )} + + {/* Upload Component */} + !pendingDeletions.has(a.attachmentId)).length} + /> + + )} +
) diff --git a/app/screens/TestDetail/TestDetailsPage.tsx b/app/screens/TestDetail/TestDetailsPage.tsx index 047c1e9..1efbb21 100644 --- a/app/screens/TestDetail/TestDetailsPage.tsx +++ b/app/screens/TestDetail/TestDetailsPage.tsx @@ -5,7 +5,7 @@ import {useCustomNavigate} from '@hooks/useCustomNavigate' import {useFetcher, useLoaderData, useParams} from '@remix-run/react' import {Button} from '@ui/button' import {cn} from '@ui/utils' -import {useEffect, useState} from 'react' +import {useEffect, useState, useCallback} from 'react' import {AddResultDialog} from '../RunTestList/AddResultDialog' import {InputLabels} from '../TestList/InputLabels' import {LinkContent, OptionContent, TextContent} from './Contents' @@ -14,6 +14,13 @@ import {shortDate2} from '~/utils/getDate' import {Tooltip} from '@components/Tooltip/Tooltip' import {IGetAllSectionsResponse} from '@controllers/sections.controller' import {getSectionHierarchy} from '@components/SectionList/utils' +import { + MediaUploader, + MediaGallery, + AttachmentSection, + Attachment, +} from '~/components/Attachments' +import {toast} from '@ui/use-toast' export default function TestDetailsPage({ pageType, @@ -30,6 +37,8 @@ export default function TestDetailsPage({ const sectionFetcher = useFetcher<{ data: IGetAllSectionsResponse[] }>() + const attachmentsFetcher = useFetcher<{data: {expected: Attachment[]; actual: Attachment[]}}>() + const deleteAttachmentFetcher = useFetcher() const [sectionsData, setSectionsData] = useState< { sectionId: number @@ -38,6 +47,8 @@ export default function TestDetailsPage({ projectId: number }[] >([]) + const [expectedAttachments, setExpectedAttachments] = useState([]) + const [actualAttachments, setActualAttachments] = useState([]) const data = resp?.data const [testStatus, setTestStatus] = useState() @@ -58,6 +69,17 @@ export default function TestDetailsPage({ : `/${API.GetTestStatusHistoryInRun}?runId=${runId}&testId=${testId}`, ) sectionFetcher.load(`/${API.GetSections}?projectId=${projectId}`) + + // Load attachments based on page type + if (pageType === 'runTestDetail') { + attachmentsFetcher.load( + `/${API.GetRunAttachments}?projectId=${projectId}&runId=${runId}&testId=${testId}`, + ) + } else { + attachmentsFetcher.load( + `/${API.GetTestAttachments}?projectId=${projectId}&testId=${testId}`, + ) + } }, [projectId, testId, runId]) useEffect(() => { @@ -72,6 +94,21 @@ export default function TestDetailsPage({ } }, [sectionFetcher.data]) + // Update attachments when fetcher completes + useEffect(() => { + if (attachmentsFetcher.data?.data) { + if (pageType === 'runTestDetail') { + // Run test detail returns both expected and actual + const attachmentsData = attachmentsFetcher.data.data as {expected: Attachment[]; actual: Attachment[]} + setExpectedAttachments(attachmentsData.expected || []) + setActualAttachments(attachmentsData.actual || []) + } else { + // Test detail returns only expected (as array) + setExpectedAttachments(attachmentsFetcher.data.data as unknown as Attachment[]) + } + } + }, [attachmentsFetcher.data, pageType]) + useEffect(() => { if (testStatusHistoryFetcher && testStatusHistoryFetcher.data) { setTestStatusHistory(testStatusHistoryFetcher.data) @@ -94,6 +131,46 @@ export default function TestDetailsPage({ ) } + // Attachment handlers + const handleActualAttachmentUpload = useCallback( + (attachment: Attachment) => { + setActualAttachments((prev) => [...prev, attachment]) + toast({ + variant: 'success', + description: 'Attachment uploaded successfully', + }) + }, + [], + ) + + const handleAttachmentUploadError = useCallback((error: string) => { + toast({ + variant: 'destructive', + description: error, + }) + }, []) + + const handleDeleteActualAttachment = useCallback( + (attachmentId: number) => { + deleteAttachmentFetcher.submit( + {attachmentId, projectId}, + { + method: 'DELETE', + action: `/${API.DeleteRunAttachment}`, + encType: 'application/json', + }, + ) + setActualAttachments((prev) => + prev.filter((a) => a.attachmentId !== attachmentId), + ) + toast({ + variant: 'success', + description: 'Attachment deleted', + }) + }, + [projectId], + ) + return (
{/* Header Section */} @@ -224,6 +301,60 @@ export default function TestDetailsPage({ />
+ + {/* Expected Behavior Attachments - Always shown */} + {expectedAttachments.length > 0 && ( +
+

+ Expected Behavior +

+

+ Screenshots and videos showing the expected behavior for this test. +

+ +
+ )} + + {/* Actual Behavior Attachments - Only shown in run test detail */} + {pageType === 'runTestDetail' && ( +
+

+ Actual Behavior +

+

+ Upload screenshots or videos showing the actual behavior observed during this test run. +

+ + {/* Show existing actual attachments */} + {actualAttachments.length > 0 && ( +
+ +
+ )} + + {/* Upload component - only if run is active */} + {testStatus?.data?.[0]?.runStatus === 'Active' && ( + + )} +
+ )} ) diff --git a/app/screens/TestList/TestDetailSlidingPanel.tsx b/app/screens/TestList/TestDetailSlidingPanel.tsx index fcd5df0..9fd8cc0 100644 --- a/app/screens/TestList/TestDetailSlidingPanel.tsx +++ b/app/screens/TestList/TestDetailSlidingPanel.tsx @@ -12,6 +12,8 @@ import {AddResultDialog} from '../RunTestList/AddResultDialog' import {TestStatusType} from '@controllers/types' import {StatusEntry} from '@api/testStatusHistory' import {TestStatusHistroyDialog} from '../TestDetail/TestStatusHistroyDialog' +import {MediaGallery, Attachment} from '~/components/Attachments' +import {ImageIcon} from 'lucide-react' export interface TestDetailAPI { additionalGroups?: string @@ -57,6 +59,12 @@ export const TestDetailDrawer = ({ }>() const navigate = useCustomNavigate() + // Attachment fetchers + const expectedAttachmentsFetcher = useFetcher<{data: Attachment[]}>() + const actualAttachmentsFetcher = useFetcher<{data: Attachment[]}>() + const [expectedAttachments, setExpectedAttachments] = useState([]) + const [actualAttachments, setActualAttachments] = useState([]) + const testDetailClicked = ( e: React.MouseEvent, ) => { @@ -74,12 +82,26 @@ export const TestDetailDrawer = ({ testDetailFetcher.load( `/${API.GetTestDetails}?projectId=${props.projectId}&testId=${props.testId}`, ) - if (pageType === 'runTestDetail') + + // Load expected behavior attachments + expectedAttachmentsFetcher.load( + `/${API.GetTestAttachments}?projectId=${props.projectId}&testId=${props.testId}`, + ) + + if (pageType === 'runTestDetail') { testStatusHistoryFetcher.load( `/${API.GetTestStatusHistoryInRun}?runId=${props.runId}&testId=${props.testId}`, ) + + // Load actual behavior attachments (run-specific) + if (props.runId) { + actualAttachmentsFetcher.load( + `/${API.GetRunAttachments}?projectId=${props.projectId}&testId=${props.testId}&runId=${props.runId}`, + ) + } + } } - }, [isOpen]) + }, [isOpen, props.projectId, props.testId, props.runId]) useEffect(() => { if (testDetailFetcher.data?.data) setData(testDetailFetcher.data?.data) @@ -91,6 +113,27 @@ export const TestDetailDrawer = ({ } }, [testStatusHistoryFetcher.data]) + // Update attachments when fetchers complete + useEffect(() => { + if (expectedAttachmentsFetcher.data?.data) { + setExpectedAttachments(expectedAttachmentsFetcher.data.data) + } + }, [expectedAttachmentsFetcher.data]) + + useEffect(() => { + if (actualAttachmentsFetcher.data?.data) { + setActualAttachments(actualAttachmentsFetcher.data.data) + } + }, [actualAttachmentsFetcher.data]) + + // Reset attachments when drawer closes + useEffect(() => { + if (!isOpen) { + setExpectedAttachments([]) + setActualAttachments([]) + } + }, [isOpen]) + return ( {data ? ( @@ -161,6 +204,60 @@ export const TestDetailDrawer = ({ heading="Additional Groups" /> + + {/* Attachments Section */} +
+
+ +

+ {pageType === 'runTestDetail' ? 'Expected Behavior' : 'Attachments'} +

+ {expectedAttachments.length > 0 && ( + + ({expectedAttachments.length} attachment{expectedAttachments.length !== 1 ? 's' : ''}) + + )} +
+ {expectedAttachments.length > 0 ? ( + + ) : ( +

+ No attachments for this test. +

+ )} +
+ + {/* Actual Behavior Attachments (Run-specific) */} + {pageType === 'runTestDetail' && ( +
+
+ +

+ Actual Behavior +

+ {actualAttachments.length > 0 && ( + + ({actualAttachments.length} attachment{actualAttachments.length !== 1 ? 's' : ''}) + + )} +
+ {actualAttachments.length > 0 ? ( + + ) : ( +

+ No actual behavior attachments uploaded for this run. +

+ )} +
+ )} diff --git a/app/services/rbac/rbacPolicyGeneration.ts b/app/services/rbac/rbacPolicyGeneration.ts index 14b0c43..290c6e1 100644 --- a/app/services/rbac/rbacPolicyGeneration.ts +++ b/app/services/rbac/rbacPolicyGeneration.ts @@ -99,6 +99,8 @@ export function generateRbacPolicy(): IRbacPolicy[] { case API.GetTestsCount: case API.GetUserDetails: case API.RunDetail: + case API.GetTestAttachments: + case API.GetRunAttachments: role = AccessType.READER action = ApiTypes.GET break @@ -107,6 +109,22 @@ export function generateRbacPolicy(): IRbacPolicy[] { role = AccessType.READER action = ApiTypes.POST break + + // Attachment upload permissions (USER role required) + case API.UploadTestAttachment: + case API.UploadRunAttachment: + case API.GetPresignedUploadUrl: + case API.ConfirmAttachmentUpload: + role = AccessType.USER + action = ApiTypes.POST + break + + // Attachment delete permissions (USER role required) + case API.DeleteTestAttachment: + case API.DeleteRunAttachment: + role = AccessType.USER + action = ApiTypes.DELETE + break } rbacPolicy.push({role, resource, action}) diff --git a/app/services/storage/StorageFactory.ts b/app/services/storage/StorageFactory.ts new file mode 100644 index 0000000..c5950a8 --- /dev/null +++ b/app/services/storage/StorageFactory.ts @@ -0,0 +1,130 @@ +/** + * Storage Factory + * + * Factory for creating storage provider instances based on configuration. + * Implements singleton pattern to reuse provider instances. + * + * Uses dynamic imports to avoid bundling cloud provider SDKs unless needed. + */ + +import {StorageProvider, StorageConfig} from './interfaces/StorageProvider' +import {LocalStorageProvider} from './providers/LocalStorageProvider' +import {logger, LogType} from '~/utils/logger' +import * as path from 'path' + +// Singleton instance +let storageInstance: StorageProvider | null = null + +/** + * Get storage configuration from environment variables + */ +function getStorageConfig(): StorageConfig { + const provider = (process.env.STORAGE_PROVIDER || 'local') as + | 'local' + | 's3' + | 'gcs' + + return { + provider, + bucket: process.env.STORAGE_BUCKET, + region: process.env.STORAGE_REGION || 'us-east-1', + endpoint: process.env.STORAGE_ENDPOINT, + accessKeyId: process.env.STORAGE_ACCESS_KEY_ID, + secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY, + publicUrl: process.env.STORAGE_PUBLIC_URL, + localPath: process.env.STORAGE_LOCAL_PATH, + } +} + +/** + * Create a storage provider based on configuration + * + * Cloud providers (S3, GCS) are loaded dynamically to avoid bundling + * their SDKs unless they're actually used. + */ +export async function createStorageProvider( + config?: Partial, +): Promise { + const resolvedConfig = config + ? {...getStorageConfig(), ...config} + : getStorageConfig() + + logger({ + type: LogType.INFO, + tag: 'StorageFactory', + message: `Creating storage provider: ${resolvedConfig.provider}`, + }) + + switch (resolvedConfig.provider) { + case 'local': + return new LocalStorageProvider({ + basePath: + resolvedConfig.localPath || + path.join(process.cwd(), 'uploads', 'attachments'), + baseUrl: resolvedConfig.publicUrl || '/api/v1/attachments/serve', + }) + + case 's3': { + if ( + !resolvedConfig.bucket || + !resolvedConfig.accessKeyId || + !resolvedConfig.secretAccessKey + ) { + throw new Error( + 'S3 storage requires bucket, accessKeyId, and secretAccessKey configuration', + ) + } + // Dynamic import to avoid bundling AWS SDK unless needed + const {S3StorageProvider} = await import('./providers/S3StorageProvider') + return new S3StorageProvider({ + bucket: resolvedConfig.bucket, + region: resolvedConfig.region || 'us-east-1', + accessKeyId: resolvedConfig.accessKeyId, + secretAccessKey: resolvedConfig.secretAccessKey, + endpoint: resolvedConfig.endpoint, + publicUrl: resolvedConfig.publicUrl, + }) + } + + case 'gcs': { + if (!resolvedConfig.bucket) { + throw new Error('GCS storage requires bucket configuration') + } + // Dynamic import to avoid bundling GCS SDK unless needed + const {GCSStorageProvider} = await import('./providers/GCSStorageProvider') + return new GCSStorageProvider({ + bucket: resolvedConfig.bucket, + projectId: process.env.GCS_PROJECT_ID || '', + keyFilename: process.env.GCS_KEY_FILENAME, + publicUrl: resolvedConfig.publicUrl, + }) + } + + default: + throw new Error(`Unsupported storage provider: ${resolvedConfig.provider}`) + } +} + +/** + * Get the singleton storage provider instance + */ +export async function getStorageProvider(): Promise { + if (!storageInstance) { + storageInstance = await createStorageProvider() + } + return storageInstance +} + +/** + * Reset the singleton instance (useful for testing) + */ +export function resetStorageProvider(): void { + storageInstance = null +} + +/** + * Set a custom storage provider instance (useful for testing) + */ +export function setStorageProvider(provider: StorageProvider): void { + storageInstance = provider +} diff --git a/app/services/storage/index.ts b/app/services/storage/index.ts new file mode 100644 index 0000000..f4983a2 --- /dev/null +++ b/app/services/storage/index.ts @@ -0,0 +1,44 @@ +/** + * Storage Module + * + * Main entry point for the storage abstraction layer. + * Provides a unified interface for file storage operations. + * + * Note: Cloud providers (S3, GCS) are loaded dynamically by the factory + * to avoid requiring their SDKs unless actually used. + */ + +// Export interfaces and types +export { + StorageProvider, + StorageConfig, + FileMetadata, + UploadResult, + PresignedUrlResult, + MediaType, + AttachmentType, + SUPPORTED_IMAGE_TYPES, + SUPPORTED_VIDEO_TYPES, + SUPPORTED_MEDIA_TYPES, + FILE_SIZE_LIMITS, + MAX_ATTACHMENTS, + getMediaTypeFromMime, + isValidFileSize, + isSupportedMediaType, + generateStorageKey, +} from './interfaces/StorageProvider' + +// Export local provider (always available, no external dependencies) +export {LocalStorageProvider} from './providers/LocalStorageProvider' + +// Note: S3StorageProvider and GCSStorageProvider are NOT exported directly +// They are loaded dynamically by the factory to avoid bundling cloud SDKs +// unless the provider is actually configured. + +// Export factory +export { + createStorageProvider, + getStorageProvider, + resetStorageProvider, + setStorageProvider, +} from './StorageFactory' diff --git a/app/services/storage/interfaces/StorageProvider.ts b/app/services/storage/interfaces/StorageProvider.ts new file mode 100644 index 0000000..56e6903 --- /dev/null +++ b/app/services/storage/interfaces/StorageProvider.ts @@ -0,0 +1,235 @@ +/** + * Storage Provider Interface + * + * This interface defines the contract for all storage providers. + * Implement this interface to add support for new storage backends + * (e.g., AWS S3, Google Cloud Storage, Azure Blob, MinIO, etc.) + */ + +export interface FileMetadata { + contentType: string + originalFilename: string + size: number + [key: string]: unknown +} + +export interface UploadResult { + key: string + url: string + size: number + contentType: string +} + +export interface PresignedUrlResult { + uploadUrl: string + key: string + expiresAt: Date +} + +export interface StorageProvider { + /** + * Get the provider name/identifier + */ + readonly name: string + + /** + * Upload a file directly to storage + * @param file - File buffer to upload + * @param key - Storage key/path for the file + * @param metadata - File metadata including content type + * @returns Upload result with URL and metadata + */ + upload( + file: Buffer, + key: string, + metadata: FileMetadata, + ): Promise + + /** + * Delete a file from storage + * @param key - Storage key/path of the file to delete + */ + delete(key: string): Promise + + /** + * Get a signed URL for reading a file (time-limited access) + * @param key - Storage key/path of the file + * @param expiresIn - Expiration time in seconds (default: 3600) + * @returns Signed URL string + */ + getSignedUrl(key: string, expiresIn?: number): Promise + + /** + * Get a presigned URL for uploading a file directly to storage + * This is useful for client-side uploads to bypass server bandwidth + * @param key - Storage key/path where the file will be stored + * @param contentType - MIME type of the file to upload + * @param expiresIn - Expiration time in seconds (default: 3600) + * @returns Presigned URL result with upload URL and metadata + */ + getPresignedUploadUrl( + key: string, + contentType: string, + expiresIn?: number, + ): Promise + + /** + * Get the public URL for a file (if bucket is public) + * @param key - Storage key/path of the file + * @returns Public URL string + */ + getPublicUrl(key: string): string + + /** + * Check if a file exists in storage + * @param key - Storage key/path to check + * @returns True if file exists, false otherwise + */ + exists(key: string): Promise + + /** + * Copy a file within storage + * @param sourceKey - Source storage key + * @param destinationKey - Destination storage key + */ + copy(sourceKey: string, destinationKey: string): Promise + + /** + * Get file metadata without downloading the file + * @param key - Storage key/path of the file + * @returns File metadata or null if not found + */ + getMetadata(key: string): Promise +} + +/** + * Storage configuration interface + */ +export interface StorageConfig { + provider: 'local' | 's3' | 'gcs' + bucket?: string + region?: string + endpoint?: string + accessKeyId?: string + secretAccessKey?: string + publicUrl?: string + localPath?: string +} + +/** + * Media type enum for categorizing uploads + */ +export enum MediaType { + IMAGE = 'image', + VIDEO = 'video', +} + +/** + * Attachment type enum for categorizing attachment context + */ +export enum AttachmentType { + EXPECTED = 'expected', // Attached to test definition (expected behavior) + ACTUAL = 'actual', // Attached during run execution (actual behavior) +} + +/** + * Supported image MIME types + */ +export const SUPPORTED_IMAGE_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', +] as const + +/** + * Supported video MIME types + */ +export const SUPPORTED_VIDEO_TYPES = [ + 'video/mp4', + 'video/webm', + 'video/quicktime', // .mov +] as const + +/** + * All supported media types + */ +export const SUPPORTED_MEDIA_TYPES = [ + ...SUPPORTED_IMAGE_TYPES, + ...SUPPORTED_VIDEO_TYPES, +] as const + +export type SupportedImageType = (typeof SUPPORTED_IMAGE_TYPES)[number] +export type SupportedVideoType = (typeof SUPPORTED_VIDEO_TYPES)[number] +export type SupportedMediaType = (typeof SUPPORTED_MEDIA_TYPES)[number] + +/** + * File size limits in bytes + */ +export const FILE_SIZE_LIMITS = { + IMAGE: 10 * 1024 * 1024, // 10MB + VIDEO: 100 * 1024 * 1024, // 100MB +} as const + +/** + * Maximum attachments per test/run + */ +export const MAX_ATTACHMENTS = { + PER_TEST: 10, + PER_RUN_TEST: 10, +} as const + +/** + * Helper function to determine media type from MIME type + */ +export function getMediaTypeFromMime(mimeType: string): MediaType | null { + if (SUPPORTED_IMAGE_TYPES.includes(mimeType as SupportedImageType)) { + return MediaType.IMAGE + } + if (SUPPORTED_VIDEO_TYPES.includes(mimeType as SupportedVideoType)) { + return MediaType.VIDEO + } + return null +} + +/** + * Helper function to validate file size based on media type + */ +export function isValidFileSize(size: number, mediaType: MediaType): boolean { + const limit = + mediaType === MediaType.IMAGE + ? FILE_SIZE_LIMITS.IMAGE + : FILE_SIZE_LIMITS.VIDEO + return size <= limit +} + +/** + * Helper function to check if a MIME type is supported + */ +export function isSupportedMediaType(mimeType: string): boolean { + return SUPPORTED_MEDIA_TYPES.includes(mimeType as SupportedMediaType) +} + +/** + * Generate a storage key for attachments + */ +export function generateStorageKey(params: { + projectId: number + testId: number + runId?: number + filename: string + attachmentType: AttachmentType +}): string { + const {projectId, testId, runId, filename, attachmentType} = params + const timestamp = Date.now() + const randomSuffix = Math.random().toString(36).substring(2, 8) + const sanitizedFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_') + + if (attachmentType === AttachmentType.EXPECTED) { + return `projects/${projectId}/tests/${testId}/expected/${timestamp}-${randomSuffix}-${sanitizedFilename}` + } + + return `projects/${projectId}/runs/${runId}/tests/${testId}/actual/${timestamp}-${randomSuffix}-${sanitizedFilename}` +} + diff --git a/app/services/storage/providers/GCSStorageProvider.ts b/app/services/storage/providers/GCSStorageProvider.ts new file mode 100644 index 0000000..c8bd4b2 --- /dev/null +++ b/app/services/storage/providers/GCSStorageProvider.ts @@ -0,0 +1,273 @@ +/** + * Google Cloud Storage Provider + * + * Implementation for Google Cloud Storage + */ + +import { + StorageProvider, + FileMetadata, + UploadResult, + PresignedUrlResult, +} from '../interfaces/StorageProvider' +import {logger, LogType} from '~/utils/logger' + +export interface GCSStorageConfig { + bucket: string + projectId: string + keyFilename?: string // Path to service account key file + credentials?: { + client_email: string + private_key: string + } + publicUrl?: string // Custom public URL (e.g., CDN URL) +} + +/** + * Google Cloud Storage Provider + * + * Note: This implementation uses the @google-cloud/storage package. + * Install required packages: + * yarn add @google-cloud/storage + */ +export class GCSStorageProvider implements StorageProvider { + readonly name = 'gcs' + private config: GCSStorageConfig + private storageClient: any + private bucket: any + private initialized: boolean = false + + constructor(config: GCSStorageConfig) { + this.config = config + } + + /** + * Lazily initialize the GCS client + */ + private async getClient(): Promise { + if (!this.initialized) { + try { + const {Storage} = await import('@google-cloud/storage') + + const clientConfig: any = { + projectId: this.config.projectId, + } + + if (this.config.keyFilename) { + clientConfig.keyFilename = this.config.keyFilename + } else if (this.config.credentials) { + clientConfig.credentials = this.config.credentials + } + + this.storageClient = new Storage(clientConfig) + this.bucket = this.storageClient.bucket(this.config.bucket) + this.initialized = true + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to initialize GCS client: ${error.message}. Make sure @google-cloud/storage is installed.`, + }) + throw new Error( + 'GCS client not available. Install @google-cloud/storage package.', + ) + } + } + return this.bucket + } + + async upload( + file: Buffer, + key: string, + metadata: FileMetadata, + ): Promise { + try { + const bucket = await this.getClient() + const blob = bucket.file(key) + + await blob.save(file, { + contentType: metadata.contentType, + metadata: { + metadata: { + originalFilename: metadata.originalFilename, + size: String(metadata.size), + }, + }, + }) + + logger({ + type: LogType.INFO, + tag: 'GCSStorage', + message: `File uploaded: ${key}`, + }) + + return { + key, + url: this.getPublicUrl(key), + size: metadata.size, + contentType: metadata.contentType, + } + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to upload file: ${error.message}`, + }) + throw new Error(`Failed to upload file to GCS: ${error.message}`) + } + } + + async delete(key: string): Promise { + try { + const bucket = await this.getClient() + const blob = bucket.file(key) + + await blob.delete() + + logger({ + type: LogType.INFO, + tag: 'GCSStorage', + message: `File deleted: ${key}`, + }) + } catch (error: any) { + // Ignore 404 errors (file already deleted) + if (error.code !== 404) { + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to delete file: ${error.message}`, + }) + throw new Error(`Failed to delete file from GCS: ${error.message}`) + } + } + } + + async getSignedUrl(key: string, expiresIn: number = 3600): Promise { + try { + const bucket = await this.getClient() + const blob = bucket.file(key) + + const [url] = await blob.getSignedUrl({ + action: 'read', + expires: Date.now() + expiresIn * 1000, + }) + + return url + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to generate signed URL: ${error.message}`, + }) + throw new Error(`Failed to generate signed URL: ${error.message}`) + } + } + + async getPresignedUploadUrl( + key: string, + contentType: string, + expiresIn: number = 3600, + ): Promise { + try { + const bucket = await this.getClient() + const blob = bucket.file(key) + + const [url] = await blob.getSignedUrl({ + action: 'write', + contentType, + expires: Date.now() + expiresIn * 1000, + }) + + return { + uploadUrl: url, + key, + expiresAt: new Date(Date.now() + expiresIn * 1000), + } + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to generate presigned upload URL: ${error.message}`, + }) + throw new Error( + `Failed to generate presigned upload URL: ${error.message}`, + ) + } + } + + getPublicUrl(key: string): string { + if (this.config.publicUrl) { + return `${this.config.publicUrl.replace(/\/$/, '')}/${key}` + } + + // Standard GCS public URL + return `https://storage.googleapis.com/${this.config.bucket}/${key}` + } + + async exists(key: string): Promise { + try { + const bucket = await this.getClient() + const blob = bucket.file(key) + const [exists] = await blob.exists() + return exists + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to check file existence: ${error.message}`, + }) + throw error + } + } + + async copy(sourceKey: string, destinationKey: string): Promise { + try { + const bucket = await this.getClient() + const sourceBlob = bucket.file(sourceKey) + const destBlob = bucket.file(destinationKey) + + await sourceBlob.copy(destBlob) + + logger({ + type: LogType.INFO, + tag: 'GCSStorage', + message: `File copied: ${sourceKey} -> ${destinationKey}`, + }) + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to copy file: ${error.message}`, + }) + throw new Error(`Failed to copy file in GCS: ${error.message}`) + } + } + + async getMetadata(key: string): Promise { + try { + const bucket = await this.getClient() + const blob = bucket.file(key) + const [metadata] = await blob.getMetadata() + + return { + contentType: metadata.contentType || 'application/octet-stream', + originalFilename: + metadata.metadata?.originalFilename || + key.split('/').pop() || + key, + size: parseInt(metadata.size, 10) || 0, + } + } catch (error: any) { + if (error.code === 404) { + return null + } + logger({ + type: LogType.ERROR, + tag: 'GCSStorage', + message: `Failed to get metadata: ${error.message}`, + }) + throw new Error(`Failed to get file metadata from GCS: ${error.message}`) + } + } +} + diff --git a/app/services/storage/providers/LocalStorageProvider.ts b/app/services/storage/providers/LocalStorageProvider.ts new file mode 100644 index 0000000..a83fb37 --- /dev/null +++ b/app/services/storage/providers/LocalStorageProvider.ts @@ -0,0 +1,228 @@ +/** + * Local Storage Provider + * + * Implementation for local filesystem storage. + * Useful for development and testing environments. + */ + +import { + StorageProvider, + FileMetadata, + UploadResult, + PresignedUrlResult, +} from '../interfaces/StorageProvider' +import * as fs from 'fs' +import * as path from 'path' +import {logger, LogType} from '~/utils/logger' + +export interface LocalStorageConfig { + basePath: string // Base directory for file storage + baseUrl: string // Base URL for serving files (e.g., http://localhost:3000/uploads) +} + +export class LocalStorageProvider implements StorageProvider { + readonly name = 'local' + private basePath: string + private baseUrl: string + + constructor(config: LocalStorageConfig) { + this.basePath = config.basePath + this.baseUrl = config.baseUrl.replace(/\/$/, '') // Remove trailing slash + + // Ensure base directory exists + this.ensureDirectoryExists(this.basePath) + } + + private ensureDirectoryExists(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, {recursive: true}) + } + } + + private getFullPath(key: string): string { + return path.join(this.basePath, key) + } + + async upload( + file: Buffer, + key: string, + metadata: FileMetadata, + ): Promise { + try { + const fullPath = this.getFullPath(key) + const dirPath = path.dirname(fullPath) + + // Ensure directory exists + this.ensureDirectoryExists(dirPath) + + // Write file + fs.writeFileSync(fullPath, file) + + // Store metadata in a sidecar file + const metadataPath = `${fullPath}.meta.json` + fs.writeFileSync( + metadataPath, + JSON.stringify({ + ...metadata, + uploadedAt: new Date().toISOString(), + }), + ) + + logger({ + type: LogType.INFO, + tag: 'LocalStorage', + message: `File uploaded: ${key}`, + }) + + return { + key, + url: this.getPublicUrl(key), + size: metadata.size, + contentType: metadata.contentType, + } + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'LocalStorage', + message: `Failed to upload file: ${error.message}`, + }) + throw new Error(`Failed to upload file: ${error.message}`) + } + } + + async delete(key: string): Promise { + try { + const fullPath = this.getFullPath(key) + const metadataPath = `${fullPath}.meta.json` + + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath) + } + + if (fs.existsSync(metadataPath)) { + fs.unlinkSync(metadataPath) + } + + logger({ + type: LogType.INFO, + tag: 'LocalStorage', + message: `File deleted: ${key}`, + }) + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'LocalStorage', + message: `Failed to delete file: ${error.message}`, + }) + throw new Error(`Failed to delete file: ${error.message}`) + } + } + + async getSignedUrl(key: string, expiresIn: number = 3600): Promise { + // For local storage, we return the public URL with a token parameter + // In production, you'd want to implement proper token-based access + const token = Buffer.from( + JSON.stringify({ + key, + expires: Date.now() + expiresIn * 1000, + }), + ).toString('base64url') + + return `${this.baseUrl}/${key}?token=${token}` + } + + async getPresignedUploadUrl( + key: string, + contentType: string, + expiresIn: number = 3600, + ): Promise { + // For local storage, we use a special upload endpoint + const token = Buffer.from( + JSON.stringify({ + key, + contentType, + expires: Date.now() + expiresIn * 1000, + }), + ).toString('base64url') + + return { + uploadUrl: `${this.baseUrl}/upload?token=${token}`, + key, + expiresAt: new Date(Date.now() + expiresIn * 1000), + } + } + + getPublicUrl(key: string): string { + return `${this.baseUrl}/${key}` + } + + async exists(key: string): Promise { + const fullPath = this.getFullPath(key) + return fs.existsSync(fullPath) + } + + async copy(sourceKey: string, destinationKey: string): Promise { + try { + const sourcePath = this.getFullPath(sourceKey) + const destPath = this.getFullPath(destinationKey) + const destDir = path.dirname(destPath) + + // Ensure destination directory exists + this.ensureDirectoryExists(destDir) + + // Copy file + fs.copyFileSync(sourcePath, destPath) + + // Copy metadata if it exists + const sourceMetaPath = `${sourcePath}.meta.json` + const destMetaPath = `${destPath}.meta.json` + if (fs.existsSync(sourceMetaPath)) { + fs.copyFileSync(sourceMetaPath, destMetaPath) + } + + logger({ + type: LogType.INFO, + tag: 'LocalStorage', + message: `File copied: ${sourceKey} -> ${destinationKey}`, + }) + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'LocalStorage', + message: `Failed to copy file: ${error.message}`, + }) + throw new Error(`Failed to copy file: ${error.message}`) + } + } + + async getMetadata(key: string): Promise { + try { + const fullPath = this.getFullPath(key) + const metadataPath = `${fullPath}.meta.json` + + if (!fs.existsSync(metadataPath)) { + // Try to get basic metadata from the file itself + if (fs.existsSync(fullPath)) { + const stats = fs.statSync(fullPath) + return { + contentType: 'application/octet-stream', + originalFilename: path.basename(key), + size: stats.size, + } + } + return null + } + + const metadataContent = fs.readFileSync(metadataPath, 'utf-8') + return JSON.parse(metadataContent) + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'LocalStorage', + message: `Failed to get metadata: ${error.message}`, + }) + return null + } + } +} + diff --git a/app/services/storage/providers/S3StorageProvider.ts b/app/services/storage/providers/S3StorageProvider.ts new file mode 100644 index 0000000..77708d1 --- /dev/null +++ b/app/services/storage/providers/S3StorageProvider.ts @@ -0,0 +1,306 @@ +/** + * AWS S3 Storage Provider + * + * Implementation for Amazon S3 and S3-compatible storage services + * (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2) + */ + +import { + StorageProvider, + FileMetadata, + UploadResult, + PresignedUrlResult, +} from '../interfaces/StorageProvider' +import {logger, LogType} from '~/utils/logger' + +export interface S3StorageConfig { + bucket: string + region: string + accessKeyId: string + secretAccessKey: string + endpoint?: string // For S3-compatible services + forcePathStyle?: boolean // For S3-compatible services + publicUrl?: string // Custom public URL (e.g., CDN URL) +} + +/** + * S3 Storage Provider + * + * Note: This implementation uses the AWS SDK v3. + * Install required packages: + * yarn add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner + */ +export class S3StorageProvider implements StorageProvider { + readonly name = 's3' + private config: S3StorageConfig + private s3Client: any // AWS S3Client + private initialized: boolean = false + + constructor(config: S3StorageConfig) { + this.config = config + } + + /** + * Lazily initialize the S3 client + * This allows the app to start even if AWS SDK is not installed + */ + private async getClient(): Promise { + if (!this.initialized) { + try { + const {S3Client} = await import('@aws-sdk/client-s3') + + const clientConfig: any = { + region: this.config.region, + credentials: { + accessKeyId: this.config.accessKeyId, + secretAccessKey: this.config.secretAccessKey, + }, + } + + if (this.config.endpoint) { + clientConfig.endpoint = this.config.endpoint + } + + if (this.config.forcePathStyle) { + clientConfig.forcePathStyle = true + } + + this.s3Client = new S3Client(clientConfig) + this.initialized = true + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'S3Storage', + message: `Failed to initialize S3 client: ${error.message}. Make sure @aws-sdk/client-s3 is installed.`, + }) + throw new Error( + 'S3 client not available. Install @aws-sdk/client-s3 package.', + ) + } + } + return this.s3Client + } + + async upload( + file: Buffer, + key: string, + metadata: FileMetadata, + ): Promise { + try { + const client = await this.getClient() + const {PutObjectCommand} = await import('@aws-sdk/client-s3') + + const command = new PutObjectCommand({ + Bucket: this.config.bucket, + Key: key, + Body: file, + ContentType: metadata.contentType, + Metadata: { + originalFilename: metadata.originalFilename, + size: String(metadata.size), + }, + }) + + await client.send(command) + + logger({ + type: LogType.INFO, + tag: 'S3Storage', + message: `File uploaded: ${key}`, + }) + + return { + key, + url: this.getPublicUrl(key), + size: metadata.size, + contentType: metadata.contentType, + } + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'S3Storage', + message: `Failed to upload file: ${error.message}`, + }) + throw new Error(`Failed to upload file to S3: ${error.message}`) + } + } + + async delete(key: string): Promise { + try { + const client = await this.getClient() + const {DeleteObjectCommand} = await import('@aws-sdk/client-s3') + + const command = new DeleteObjectCommand({ + Bucket: this.config.bucket, + Key: key, + }) + + await client.send(command) + + logger({ + type: LogType.INFO, + tag: 'S3Storage', + message: `File deleted: ${key}`, + }) + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'S3Storage', + message: `Failed to delete file: ${error.message}`, + }) + throw new Error(`Failed to delete file from S3: ${error.message}`) + } + } + + async getSignedUrl(key: string, expiresIn: number = 3600): Promise { + try { + const client = await this.getClient() + const {GetObjectCommand} = await import('@aws-sdk/client-s3') + const {getSignedUrl} = await import('@aws-sdk/s3-request-presigner') + + const command = new GetObjectCommand({ + Bucket: this.config.bucket, + Key: key, + }) + + const url = await getSignedUrl(client, command, {expiresIn}) + return url + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'S3Storage', + message: `Failed to generate signed URL: ${error.message}`, + }) + throw new Error(`Failed to generate signed URL: ${error.message}`) + } + } + + async getPresignedUploadUrl( + key: string, + contentType: string, + expiresIn: number = 3600, + ): Promise { + try { + const client = await this.getClient() + const {PutObjectCommand} = await import('@aws-sdk/client-s3') + const {getSignedUrl} = await import('@aws-sdk/s3-request-presigner') + + const command = new PutObjectCommand({ + Bucket: this.config.bucket, + Key: key, + ContentType: contentType, + }) + + const uploadUrl = await getSignedUrl(client, command, {expiresIn}) + + return { + uploadUrl, + key, + expiresAt: new Date(Date.now() + expiresIn * 1000), + } + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'S3Storage', + message: `Failed to generate presigned upload URL: ${error.message}`, + }) + throw new Error( + `Failed to generate presigned upload URL: ${error.message}`, + ) + } + } + + getPublicUrl(key: string): string { + if (this.config.publicUrl) { + return `${this.config.publicUrl.replace(/\/$/, '')}/${key}` + } + + if (this.config.endpoint) { + // S3-compatible service + return `${this.config.endpoint}/${this.config.bucket}/${key}` + } + + // Standard AWS S3 URL + return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}` + } + + async exists(key: string): Promise { + try { + const client = await this.getClient() + const {HeadObjectCommand} = await import('@aws-sdk/client-s3') + + const command = new HeadObjectCommand({ + Bucket: this.config.bucket, + Key: key, + }) + + await client.send(command) + return true + } catch (error: any) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + return false + } + throw error + } + } + + async copy(sourceKey: string, destinationKey: string): Promise { + try { + const client = await this.getClient() + const {CopyObjectCommand} = await import('@aws-sdk/client-s3') + + const command = new CopyObjectCommand({ + Bucket: this.config.bucket, + CopySource: `${this.config.bucket}/${sourceKey}`, + Key: destinationKey, + }) + + await client.send(command) + + logger({ + type: LogType.INFO, + tag: 'S3Storage', + message: `File copied: ${sourceKey} -> ${destinationKey}`, + }) + } catch (error: any) { + logger({ + type: LogType.ERROR, + tag: 'S3Storage', + message: `Failed to copy file: ${error.message}`, + }) + throw new Error(`Failed to copy file in S3: ${error.message}`) + } + } + + async getMetadata(key: string): Promise { + try { + const client = await this.getClient() + const {HeadObjectCommand} = await import('@aws-sdk/client-s3') + + const command = new HeadObjectCommand({ + Bucket: this.config.bucket, + Key: key, + }) + + const response = await client.send(command) + + return { + contentType: response.ContentType || 'application/octet-stream', + originalFilename: + response.Metadata?.originalFilename || key.split('/').pop() || key, + size: response.ContentLength || 0, + } + } catch (error: any) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + return null + } + logger({ + type: LogType.ERROR, + tag: 'S3Storage', + message: `Failed to get metadata: ${error.message}`, + }) + throw new Error(`Failed to get file metadata from S3: ${error.message}`) + } + } +} + diff --git a/db-migration.ts b/db-migration.ts index 3c8108d..9900c7f 100644 --- a/db-migration.ts +++ b/db-migration.ts @@ -2,6 +2,34 @@ import 'dotenv/config' import {migrate} from 'drizzle-orm/mysql2/migrator' import {client, dbClient} from '~/db/client' -await migrate(dbClient, {migrationsFolder: './drizzle'}) +async function runMigrations() { + console.log('🔄 Starting database migration...') + + try { + await migrate(dbClient, {migrationsFolder: './drizzle'}) + console.log('✅ Database migrations completed successfully!') + } catch (error: any) { + // Handle "table already exists" errors gracefully + if (error.code === 'ER_TABLE_EXISTS_ERROR' || error.errno === 1050) { + console.log('ℹ️ Some tables already exist - this is expected for existing databases') + console.log(' Migration may have been partially applied or tables were created manually') + } else if (error.message?.includes('already exists')) { + console.log('ℹ️ Migration already applied or tables exist') + } else { + console.error('❌ Migration failed:', error.message) + throw error + } + } finally { + await client.end() + } +} -await client.end() +runMigrations() + .then(() => { + console.log('🏁 Migration process finished') + process.exit(0) + }) + .catch((error) => { + console.error('💥 Migration process failed:', error) + process.exit(1) + }) diff --git a/docker-compose.yml b/docker-compose.yml index 6ba4c91..69175a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,8 +59,7 @@ services: - 3000:3000 networks: - checkmate-network - command: > - sh -c "yarn start" + # Entrypoint script handles migrations + startup checkmate-mcp: build: diff --git a/dockerfile b/dockerfile index a04e320..22ba697 100644 --- a/dockerfile +++ b/dockerfile @@ -10,10 +10,18 @@ RUN apt-get update && apt-get install -y python3 build-essential # Reinstall dependencies and handle optional dependencies RUN corepack enable && corepack prepare yarn@4.0.0 --activate -# Copy application code +# Copy application code (includes drizzle/ if it exists) COPY . . + RUN yarn install RUN yarn build +# Copy and set up entrypoint script +COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + EXPOSE 3000 + +# Use entrypoint script for migration + startup +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/mcp-server/src/tools/confirmAttachmentUpload/index.ts b/mcp-server/src/tools/confirmAttachmentUpload/index.ts new file mode 100644 index 0000000..fb77fb1 --- /dev/null +++ b/mcp-server/src/tools/confirmAttachmentUpload/index.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { handleApiResponse } from '../utils.js'; + +export default function registerConfirmAttachmentUpload( + server: McpServer, + makeRequest: (path: string, init?: RequestInit) => Promise, +) { + server.tool( + 'confirm-attachment-upload', + 'Confirm an attachment upload after successfully uploading to the presigned URL. This creates the database record for the attachment.', + { + projectId: z.number().int().positive().describe('Project ID'), + testId: z.number().int().positive().describe('Test ID'), + runId: z.number().int().positive().optional().describe('Run ID (required for actual behavior attachments)'), + storageKey: z.string().min(1).describe('Storage key returned from get-presigned-upload-url'), + filename: z.string().min(1).describe('Original filename'), + mimeType: z.string().min(1).describe('MIME type'), + fileSize: z.number().int().positive().describe('File size in bytes'), + description: z.string().optional().describe('Optional description for the attachment'), + attachmentType: z.enum(['expected', 'actual']).describe('Type: expected (test-level) or actual (run-level)'), + }, + async ({ projectId, testId, runId, storageKey, filename, mimeType, fileSize, description, attachmentType }) => { + try { + // Validate runId for actual attachments + if (attachmentType === 'actual' && !runId) { + return { + content: [ + { + type: 'text', + text: '❌ Error: runId is required for actual behavior attachments.\n\n💡 Tip: Provide a runId when confirming attachments during test execution.', + }, + ], + isError: true, + }; + } + + const data = await makeRequest('api/v1/attachments/confirm-upload', { + method: 'POST', + body: JSON.stringify({ + projectId, + testId, + runId, + storageKey, + filename, + mimeType, + fileSize, + description, + attachmentType, + }), + }); + + return handleApiResponse( + data, + `confirm attachment upload`, + [ + 'projectId (number, required): Project ID', + 'testId (number, required): Test ID', + 'runId (number, optional): Run ID (required for actual attachments)', + 'storageKey (string, required): Storage key from presigned URL', + 'filename (string, required): Original filename', + 'mimeType (string, required): MIME type', + 'fileSize (number, required): File size in bytes', + 'description (string, optional): Attachment description', + 'attachmentType (string, required): "expected" or "actual"', + ], + ); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `❌ Error confirming attachment upload: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Tip: Make sure the file was successfully uploaded to the presigned URL before confirming.`, + }, + ], + isError: true, + }; + } + }, + ); +} + diff --git a/mcp-server/src/tools/deleteRunAttachment/index.ts b/mcp-server/src/tools/deleteRunAttachment/index.ts new file mode 100644 index 0000000..6e74136 --- /dev/null +++ b/mcp-server/src/tools/deleteRunAttachment/index.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { handleApiResponse } from '../utils.js'; + +export default function registerDeleteRunAttachment( + server: McpServer, + makeRequest: (path: string, init?: RequestInit) => Promise, +) { + server.tool( + 'delete-run-attachment', + 'Delete an attachment from a test within a run (actual behavior attachment).', + { + projectId: z.number().int().positive().describe('Project ID'), + attachmentId: z.number().int().positive().describe('Attachment ID to delete'), + }, + async ({ projectId, attachmentId }) => { + try { + const data = await makeRequest('api/v1/run/attachments/delete', { + method: 'DELETE', + body: JSON.stringify({ + projectId, + attachmentId, + }), + }); + + return handleApiResponse( + data, + `delete run attachment ${attachmentId}`, + [ + 'projectId (number, required): Project ID', + 'attachmentId (number, required): Attachment ID to delete', + ], + ); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `❌ Error deleting run attachment: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Tip: Use get-run-attachments to find valid attachment IDs.`, + }, + ], + isError: true, + }; + } + }, + ); +} + diff --git a/mcp-server/src/tools/deleteTestAttachment/index.ts b/mcp-server/src/tools/deleteTestAttachment/index.ts new file mode 100644 index 0000000..fbbea08 --- /dev/null +++ b/mcp-server/src/tools/deleteTestAttachment/index.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { handleApiResponse } from '../utils.js'; + +export default function registerDeleteTestAttachment( + server: McpServer, + makeRequest: (path: string, init?: RequestInit) => Promise, +) { + server.tool( + 'delete-test-attachment', + 'Delete an attachment from a test case (expected behavior attachment).', + { + projectId: z.number().int().positive().describe('Project ID'), + attachmentId: z.number().int().positive().describe('Attachment ID to delete'), + }, + async ({ projectId, attachmentId }) => { + try { + const data = await makeRequest('api/v1/test/attachments/delete', { + method: 'DELETE', + body: JSON.stringify({ + projectId, + attachmentId, + }), + }); + + return handleApiResponse( + data, + `delete test attachment ${attachmentId}`, + [ + 'projectId (number, required): Project ID', + 'attachmentId (number, required): Attachment ID to delete', + ], + ); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `❌ Error deleting test attachment: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Tip: Use get-test-attachments to find valid attachment IDs.`, + }, + ], + isError: true, + }; + } + }, + ); +} + diff --git a/mcp-server/src/tools/getPresignedUploadUrl/index.ts b/mcp-server/src/tools/getPresignedUploadUrl/index.ts new file mode 100644 index 0000000..be4eca5 --- /dev/null +++ b/mcp-server/src/tools/getPresignedUploadUrl/index.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { handleApiResponse } from '../utils.js'; + +export default function registerGetPresignedUploadUrl( + server: McpServer, + makeRequest: (path: string, init?: RequestInit) => Promise, +) { + server.tool( + 'get-presigned-upload-url', + 'Get a presigned URL for uploading an attachment directly to storage. Use this for uploading screenshots or videos. After uploading to the presigned URL, call confirm-attachment-upload to save the attachment.', + { + projectId: z.number().int().positive().describe('Project ID'), + testId: z.number().int().positive().describe('Test ID'), + runId: z.number().int().positive().optional().describe('Run ID (required for actual behavior attachments)'), + filename: z.string().min(1).describe('Original filename with extension (e.g., screenshot.png)'), + mimeType: z.string().min(1).describe('MIME type (e.g., image/png, video/mp4)'), + fileSize: z.number().int().positive().describe('File size in bytes'), + attachmentType: z.enum(['expected', 'actual']).describe('Type: expected (test-level) or actual (run-level)'), + }, + async ({ projectId, testId, runId, filename, mimeType, fileSize, attachmentType }) => { + try { + // Validate runId for actual attachments + if (attachmentType === 'actual' && !runId) { + return { + content: [ + { + type: 'text', + text: '❌ Error: runId is required for actual behavior attachments.\n\n💡 Tip: Provide a runId when uploading attachments during test execution.', + }, + ], + isError: true, + }; + } + + const data = await makeRequest('api/v1/attachments/presigned-url', { + method: 'POST', + body: JSON.stringify({ + projectId, + testId, + runId, + filename, + mimeType, + fileSize, + attachmentType, + }), + }); + + if (data && typeof data === 'object' && 'data' in data) { + const responseData = data as { data: { uploadUrl: string; storageKey: string; expiresAt: string } }; + return { + content: [ + { + type: 'text', + text: `✅ Presigned upload URL generated successfully!\n\n` + + `📤 **Upload URL**: ${responseData.data.uploadUrl}\n\n` + + `🔑 **Storage Key**: ${responseData.data.storageKey}\n\n` + + `⏰ **Expires At**: ${responseData.data.expiresAt}\n\n` + + `📝 **Next Steps**:\n` + + `1. Upload your file to the URL using PUT request with Content-Type: ${mimeType}\n` + + `2. After successful upload, call confirm-attachment-upload with:\n` + + ` - storageKey: "${responseData.data.storageKey}"\n` + + ` - filename: "${filename}"\n` + + ` - mimeType: "${mimeType}"\n` + + ` - fileSize: ${fileSize}\n` + + ` - attachmentType: "${attachmentType}"\n`, + }, + ], + }; + } + + return handleApiResponse( + data, + `generate presigned upload URL`, + [ + 'projectId (number, required): Project ID', + 'testId (number, required): Test ID', + 'runId (number, optional): Run ID (required for actual attachments)', + 'filename (string, required): Original filename', + 'mimeType (string, required): MIME type', + 'fileSize (number, required): File size in bytes', + 'attachmentType (string, required): "expected" or "actual"', + ], + ); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `❌ Error generating presigned URL: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Supported file types:\n- Images: PNG, JPEG, GIF, WebP (max 10MB)\n- Videos: MP4, WebM, MOV (max 100MB)`, + }, + ], + isError: true, + }; + } + }, + ); +} + diff --git a/mcp-server/src/tools/getRunAttachments/index.ts b/mcp-server/src/tools/getRunAttachments/index.ts new file mode 100644 index 0000000..99908a1 --- /dev/null +++ b/mcp-server/src/tools/getRunAttachments/index.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { handleApiResponse } from '../utils.js'; + +export default function registerGetRunAttachments( + server: McpServer, + makeRequest: (path: string, init?: RequestInit) => Promise, +) { + server.tool( + 'get-run-attachments', + 'Get all attachments for a test within a run. Returns both expected (from test definition) and actual (from run execution) attachments.', + { + projectId: z.number().int().positive().describe('Project ID'), + runId: z.number().int().positive().describe('Run ID'), + testId: z.number().int().positive().describe('Test ID'), + }, + async ({ projectId, runId, testId }) => { + try { + const qs = new URLSearchParams({ + projectId: String(projectId), + runId: String(runId), + testId: String(testId), + }); + + const data = await makeRequest(`api/v1/run/attachments?${qs.toString()}`); + return handleApiResponse( + data, + `retrieve attachments for test ${testId} in run ${runId}`, + [ + 'projectId (number, required): Project ID', + 'runId (number, required): Run ID', + 'testId (number, required): Test ID', + ], + ); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `❌ Error retrieving run attachments: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Tip: Use get-run-tests-list to find valid test IDs in a run.`, + }, + ], + isError: true, + }; + } + }, + ); +} + diff --git a/mcp-server/src/tools/getTestAttachments/index.ts b/mcp-server/src/tools/getTestAttachments/index.ts new file mode 100644 index 0000000..af10f63 --- /dev/null +++ b/mcp-server/src/tools/getTestAttachments/index.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { handleApiResponse } from '../utils.js'; + +export default function registerGetTestAttachments( + server: McpServer, + makeRequest: (path: string, init?: RequestInit) => Promise, +) { + server.tool( + 'get-test-attachments', + 'Get all attachments (screenshots/videos) for a test case. These represent expected behavior.', + { + projectId: z.number().int().positive().describe('Project ID'), + testId: z.number().int().positive().describe('Test ID'), + }, + async ({ projectId, testId }) => { + try { + const qs = new URLSearchParams({ + projectId: String(projectId), + testId: String(testId), + }); + + const data = await makeRequest(`api/v1/test/attachments?${qs.toString()}`); + return handleApiResponse( + data, + `retrieve attachments for test ${testId}`, + [ + 'projectId (number, required): Project ID', + 'testId (number, required): Test ID', + ], + ); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `❌ Error retrieving test attachments: ${error instanceof Error ? error.message : 'Unknown error'}\n\n💡 Tip: Use get-tests to find valid test IDs.`, + }, + ], + isError: true, + }; + } + }, + ); +} + diff --git a/package.json b/package.json index 41a75ff..5b2d066 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "db:init": "yarn db:schema:push && yarn db:seed", "db:introspect": "drizzle-kit introspect", "db:migrate": "npx tsx db-migration.ts", + "db:migrate:run": "npx tsx scripts/migration-runner.ts", "db:schema:push": "yarn db:generate && yarn db:migrate", "db:seed": "npx tsx app/db/seed/seed.ts && sh ./scripts/insertTests.sh", "db:studio": "drizzle-kit studio --port 4000", diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..f1eb4b3 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -e + +echo "════════════════════════════════════════════════════════════════" +echo "🚀 Checkmate Application Startup" +echo "════════════════════════════════════════════════════════════════" +echo "" + +# Wait for database to be ready +echo "⏳ Waiting for database connection..." +MAX_RETRIES=30 +RETRY_COUNT=0 + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if node -e " + const mysql = require('mysql2/promise'); + (async () => { + try { + const conn = await mysql.createConnection(process.env.DB_URL); + await conn.query('SELECT 1'); + await conn.end(); + process.exit(0); + } catch (e) { + process.exit(1); + } + })(); + " 2>/dev/null; then + echo "✅ Database is ready!" + echo "" + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo " Attempt $RETRY_COUNT/$MAX_RETRIES - Database not ready, waiting..." + sleep 2 +done + +if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "❌ Failed to connect to database after $MAX_RETRIES attempts" + exit 1 +fi + +# Run database migrations +echo "════════════════════════════════════════════════════════════════" +echo "📦 Database Migration" +echo "════════════════════════════════════════════════════════════════" +echo "" + +if npx tsx scripts/migration-runner.ts; then + echo "" + echo "✅ Migration process completed!" +else + MIGRATION_EXIT_CODE=$? + echo "" + echo "⚠️ Migration process exited with code $MIGRATION_EXIT_CODE" + echo " The application will still attempt to start..." +fi + +echo "" +echo "════════════════════════════════════════════════════════════════" +echo "🎯 Starting Application Server" +echo "════════════════════════════════════════════════════════════════" +echo "" + +exec node server.mjs diff --git a/scripts/migration-runner.ts b/scripts/migration-runner.ts new file mode 100644 index 0000000..6f1a870 --- /dev/null +++ b/scripts/migration-runner.ts @@ -0,0 +1,227 @@ +/** + * Production Migration Runner + * + * This script handles database migrations properly for both: + * - Fresh deployments (seedData.sql creates baseline, mark as applied) + * - Existing databases (apply only pending migrations) + * + * How it works: + * 1. Checks if __drizzle_migrations table exists + * 2. If not, this is likely a fresh DB from seedData.sql + * - Creates the migration tracking table + * - Marks existing migrations as applied (baseline) + * 3. Runs any pending migrations using Drizzle + * + * This approach is scalable - just add new Drizzle migrations + * and they'll be automatically applied on next deployment. + */ + +import 'dotenv/config' +import {sql} from 'drizzle-orm' +import {migrate} from 'drizzle-orm/mysql2/migrator' +import * as fs from 'fs' +import * as path from 'path' +import {client, dbClient} from '~/db/client' + +interface MigrationEntry { + idx: number + version: string + when: number + tag: string + breakpoints: boolean +} + +interface MigrationJournal { + version: string + dialect: string + entries: MigrationEntry[] +} + +interface TableCheckResult { + count: number +} + +async function tableExists(tableName: string): Promise { + const result = await dbClient.execute(sql` + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ${tableName} + `) + const rows = result[0] as TableCheckResult[] + return rows[0]?.count > 0 +} + +async function getMigrationJournal(): Promise { + const journalPath = path.join(process.cwd(), 'drizzle', 'meta', '_journal.json') + + if (!fs.existsSync(journalPath)) { + console.log('⚠️ No migration journal found at', journalPath) + return null + } + + const content = fs.readFileSync(journalPath, 'utf-8') + return JSON.parse(content) as MigrationJournal +} + +async function getAppliedMigrations(): Promise> { + const applied = new Set() + + try { + const result = await dbClient.execute(sql` + SELECT hash FROM __drizzle_migrations + `) + const rows = result[0] as {hash: string}[] + for (const row of rows) { + applied.add(row.hash) + } + } catch { + // Table doesn't exist yet + } + + return applied +} + +async function markMigrationAsApplied(tag: string, hash: string): Promise { + const timestamp = Date.now() + await dbClient.execute(sql` + INSERT INTO __drizzle_migrations (hash, created_at) + VALUES (${hash}, ${timestamp}) + `) + console.log(` ✓ Marked migration ${tag} as applied`) +} + +async function createMigrationTable(): Promise { + await dbClient.execute(sql` + CREATE TABLE IF NOT EXISTS __drizzle_migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + hash VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL + ) + `) +} + +async function checkIfMigrationTablesExist(journal: MigrationJournal): Promise> { + const tablesForMigration = new Map() + + // For each migration, check if its tables already exist + // This is a heuristic - we check known table patterns + for (const entry of journal.entries) { + const migrationFile = path.join(process.cwd(), 'drizzle', `${entry.tag}.sql`) + if (fs.existsSync(migrationFile)) { + const content = fs.readFileSync(migrationFile, 'utf-8') + + // Extract table names from CREATE TABLE statements + const createTableMatches = content.matchAll(/CREATE TABLE [`"]?(\w+)[`"]?/gi) + let allTablesExist = true + + for (const match of createTableMatches) { + const tableName = match[1] + const exists = await tableExists(tableName) + if (!exists) { + allTablesExist = false + break + } + } + + tablesForMigration.set(entry.tag, allTablesExist) + } + } + + return tablesForMigration +} + +async function runMigrations(): Promise { + console.log('🔄 Starting Migration Runner...') + console.log('') + + // Step 1: Read migration journal + const journal = await getMigrationJournal() + if (!journal) { + console.log('ℹ️ No migrations to run') + return + } + + console.log(`📋 Found ${journal.entries.length} migration(s) in journal`) + + // Step 2: Check if migration tracking table exists + const migrationTableExists = await tableExists('__drizzle_migrations') + + if (!migrationTableExists) { + console.log('📦 Creating migration tracking table...') + await createMigrationTable() + + // Step 3: Check which migrations' tables already exist (from seedData.sql) + console.log('🔍 Checking for baseline tables...') + const existingTables = await checkIfMigrationTablesExist(journal) + + // Mark migrations as applied if their tables already exist + for (const entry of journal.entries) { + const tablesExist = existingTables.get(entry.tag) + if (tablesExist) { + console.log(` 📌 Migration ${entry.tag} - tables already exist (baseline)`) + await markMigrationAsApplied(entry.tag, entry.tag) + } + } + } + + // Step 4: Get list of applied migrations + const appliedMigrations = await getAppliedMigrations() + console.log(`✅ ${appliedMigrations.size} migration(s) already applied`) + + // Step 5: Determine pending migrations + const pendingMigrations = journal.entries.filter( + entry => !appliedMigrations.has(entry.tag) + ) + + if (pendingMigrations.length === 0) { + console.log('') + console.log('🎉 Database is up to date! No pending migrations.') + return + } + + console.log(`⏳ ${pendingMigrations.length} migration(s) pending...`) + console.log('') + + // Step 6: Run pending migrations using Drizzle + for (const migration of pendingMigrations) { + console.log(`🔧 Applying migration: ${migration.tag}`) + + try { + // Run the specific migration + await migrate(dbClient, { + migrationsFolder: './drizzle', + }) + console.log(` ✅ Migration ${migration.tag} applied successfully`) + } catch (error: any) { + if (error.code === 'ER_TABLE_EXISTS_ERROR' || error.errno === 1050) { + // Table already exists - mark as applied + console.log(` ⚠️ Tables already exist, marking as applied`) + await markMigrationAsApplied(migration.tag, migration.tag) + } else if (error.code === 'ER_DUP_ENTRY') { + // Migration already tracked + console.log(` ℹ️ Migration already tracked`) + } else { + throw error + } + } + } + + console.log('') + console.log('🏁 Migration runner completed!') +} + +// Main execution +runMigrations() + .then(async () => { + await client.end() + process.exit(0) + }) + .catch(async (error) => { + console.error('') + console.error('❌ Migration failed:', error.message) + console.error('') + await client.end() + process.exit(1) + }) + diff --git a/seedData.sql b/seedData.sql index b66ec8e..38bd7f5 100644 --- a/seedData.sql +++ b/seedData.sql @@ -695,6 +695,93 @@ LOCK TABLES `users` WRITE; INSERT INTO `users` VALUES (1,'Seed Admin','admin@example.com',NULL,NULL,'admin','dummy1',NULL,'active'),(2,'Seed User','user@example.com',NULL,NULL,'user','dummy2',NULL,'active'),(3,'Seed Reader','reader@example.com',NULL,NULL,'reader','dummy3',NULL,'active'); /*!40000 ALTER TABLE `users` ENABLE KEYS */; UNLOCK TABLES; + +-- +-- Table structure for table `testAttachments` +-- + +DROP TABLE IF EXISTS `testAttachments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `testAttachments` ( + `attachmentId` int NOT NULL AUTO_INCREMENT, + `testId` int NOT NULL, + `projectId` int NOT NULL, + `storageKey` varchar(500) NOT NULL, + `originalFilename` varchar(255) NOT NULL, + `mimeType` varchar(100) NOT NULL, + `fileSize` int NOT NULL, + `mediaType` enum('image','video') NOT NULL, + `description` text, + `displayOrder` int DEFAULT '0', + `createdBy` int DEFAULT NULL, + `createdOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updatedOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`attachmentId`), + KEY `testAttachmentsTestIdIndex` (`testId`), + KEY `testAttachmentsProjectIdIndex` (`projectId`), + KEY `testAttachments_createdBy_users_userId_fk` (`createdBy`), + CONSTRAINT `testAttachments_testId_tests_testId_fk` FOREIGN KEY (`testId`) REFERENCES `tests` (`testId`) ON DELETE CASCADE, + CONSTRAINT `testAttachments_projectId_projects_projectId_fk` FOREIGN KEY (`projectId`) REFERENCES `projects` (`projectId`) ON DELETE CASCADE, + CONSTRAINT `testAttachments_createdBy_users_userId_fk` FOREIGN KEY (`createdBy`) REFERENCES `users` (`userId`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `testAttachments` +-- + +LOCK TABLES `testAttachments` WRITE; +/*!40000 ALTER TABLE `testAttachments` DISABLE KEYS */; +/*!40000 ALTER TABLE `testAttachments` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `runAttachments` +-- + +DROP TABLE IF EXISTS `runAttachments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `runAttachments` ( + `attachmentId` int NOT NULL AUTO_INCREMENT, + `runId` int NOT NULL, + `testId` int NOT NULL, + `testRunMapId` int DEFAULT NULL, + `projectId` int NOT NULL, + `storageKey` varchar(500) NOT NULL, + `originalFilename` varchar(255) NOT NULL, + `mimeType` varchar(100) NOT NULL, + `fileSize` int NOT NULL, + `mediaType` enum('image','video') NOT NULL, + `description` text, + `displayOrder` int DEFAULT '0', + `createdBy` int DEFAULT NULL, + `createdOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updatedOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`attachmentId`), + KEY `runAttachmentsRunTestIndex` (`runId`,`testId`), + KEY `runAttachmentsProjectIdIndex` (`projectId`), + KEY `runAttachments_testId_tests_testId_fk` (`testId`), + KEY `runAttachments_testRunMapId_testRunMap_testRunMapId_fk` (`testRunMapId`), + KEY `runAttachments_createdBy_users_userId_fk` (`createdBy`), + CONSTRAINT `runAttachments_runId_runs_runId_fk` FOREIGN KEY (`runId`) REFERENCES `runs` (`runId`) ON DELETE CASCADE, + CONSTRAINT `runAttachments_testId_tests_testId_fk` FOREIGN KEY (`testId`) REFERENCES `tests` (`testId`) ON DELETE CASCADE, + CONSTRAINT `runAttachments_testRunMapId_testRunMap_testRunMapId_fk` FOREIGN KEY (`testRunMapId`) REFERENCES `testRunMap` (`testRunMapId`) ON DELETE CASCADE, + CONSTRAINT `runAttachments_projectId_projects_projectId_fk` FOREIGN KEY (`projectId`) REFERENCES `projects` (`projectId`) ON DELETE CASCADE, + CONSTRAINT `runAttachments_createdBy_users_userId_fk` FOREIGN KEY (`createdBy`) REFERENCES `users` (`userId`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `runAttachments` +-- + +LOCK TABLES `runAttachments` WRITE; +/*!40000 ALTER TABLE `runAttachments` DISABLE KEYS */; +/*!40000 ALTER TABLE `runAttachments` ENABLE KEYS */; +UNLOCK TABLES; + /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; diff --git a/sql/add_attachments_tables.sql b/sql/add_attachments_tables.sql new file mode 100644 index 0000000..4965cf6 --- /dev/null +++ b/sql/add_attachments_tables.sql @@ -0,0 +1,85 @@ +-- Manual Migration: Add attachment tables for media uploads +-- Run this SQL directly if drizzle migration fails +-- Execute with: mysql -u root -p checkmate < sql/add_attachments_tables.sql + +-- Check if tables already exist and skip if they do +-- Create testAttachments table +CREATE TABLE IF NOT EXISTS `testAttachments` ( + `attachmentId` int AUTO_INCREMENT NOT NULL, + `testId` int NOT NULL, + `projectId` int NOT NULL, + `storageKey` varchar(500) NOT NULL, + `originalFilename` varchar(255) NOT NULL, + `mimeType` varchar(100) NOT NULL, + `fileSize` int NOT NULL, + `mediaType` enum('image','video') NOT NULL, + `description` text, + `displayOrder` int DEFAULT 0, + `createdBy` int, + `createdOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updatedOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `testAttachments_attachmentId` PRIMARY KEY(`attachmentId`) +); + +-- Create runAttachments table +CREATE TABLE IF NOT EXISTS `runAttachments` ( + `attachmentId` int AUTO_INCREMENT NOT NULL, + `runId` int NOT NULL, + `testId` int NOT NULL, + `testRunMapId` int, + `projectId` int NOT NULL, + `storageKey` varchar(500) NOT NULL, + `originalFilename` varchar(255) NOT NULL, + `mimeType` varchar(100) NOT NULL, + `fileSize` int NOT NULL, + `mediaType` enum('image','video') NOT NULL, + `description` text, + `displayOrder` int DEFAULT 0, + `createdBy` int, + `createdOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updatedOn` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `runAttachments_attachmentId` PRIMARY KEY(`attachmentId`) +); + +-- Add foreign keys for testAttachments (ignore errors if already exist) +ALTER TABLE `testAttachments` +ADD CONSTRAINT `testAttachments_testId_tests_testId_fk` +FOREIGN KEY (`testId`) REFERENCES `tests`(`testId`) ON DELETE cascade ON UPDATE no action; + +ALTER TABLE `testAttachments` +ADD CONSTRAINT `testAttachments_projectId_projects_projectId_fk` +FOREIGN KEY (`projectId`) REFERENCES `projects`(`projectId`) ON DELETE cascade ON UPDATE no action; + +ALTER TABLE `testAttachments` +ADD CONSTRAINT `testAttachments_createdBy_users_userId_fk` +FOREIGN KEY (`createdBy`) REFERENCES `users`(`userId`) ON DELETE set null ON UPDATE no action; + +-- Add foreign keys for runAttachments +ALTER TABLE `runAttachments` +ADD CONSTRAINT `runAttachments_runId_runs_runId_fk` +FOREIGN KEY (`runId`) REFERENCES `runs`(`runId`) ON DELETE cascade ON UPDATE no action; + +ALTER TABLE `runAttachments` +ADD CONSTRAINT `runAttachments_testId_tests_testId_fk` +FOREIGN KEY (`testId`) REFERENCES `tests`(`testId`) ON DELETE cascade ON UPDATE no action; + +ALTER TABLE `runAttachments` +ADD CONSTRAINT `runAttachments_testRunMapId_testRunMap_testRunMapId_fk` +FOREIGN KEY (`testRunMapId`) REFERENCES `testRunMap`(`testRunMapId`) ON DELETE cascade ON UPDATE no action; + +ALTER TABLE `runAttachments` +ADD CONSTRAINT `runAttachments_projectId_projects_projectId_fk` +FOREIGN KEY (`projectId`) REFERENCES `projects`(`projectId`) ON DELETE cascade ON UPDATE no action; + +ALTER TABLE `runAttachments` +ADD CONSTRAINT `runAttachments_createdBy_users_userId_fk` +FOREIGN KEY (`createdBy`) REFERENCES `users`(`userId`) ON DELETE set null ON UPDATE no action; + +-- Add indexes +CREATE INDEX `testAttachmentsTestIdIndex` ON `testAttachments` (`testId`); +CREATE INDEX `testAttachmentsProjectIdIndex` ON `testAttachments` (`projectId`); +CREATE INDEX `runAttachmentsRunTestIndex` ON `runAttachments` (`runId`,`testId`); +CREATE INDEX `runAttachmentsProjectIdIndex` ON `runAttachments` (`projectId`); + +SELECT 'Attachment tables created successfully!' as status; + diff --git a/vite.config.ts b/vite.config.ts index 8780fca..46488ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,13 +28,29 @@ export default defineConfig({ }, }), tsconfigPaths(), - , ], esbuild: { target: 'es2022', }, build: { target: 'es2022', + rollupOptions: { + // Externalize optional cloud storage SDKs + // These are only needed if STORAGE_PROVIDER is set to 's3' or 'gcs' + external: [ + '@aws-sdk/client-s3', + '@aws-sdk/s3-request-presigner', + '@google-cloud/storage', + ], + }, + }, + ssr: { + // Also externalize for SSR bundle + external: [ + '@aws-sdk/client-s3', + '@aws-sdk/s3-request-presigner', + '@google-cloud/storage', + ], }, server: { port: 1200, From 292842ae98080d342d2ede370f0b431f8e7c2376 Mon Sep 17 00:00:00 2001 From: Mayank Kush Date: Wed, 3 Dec 2025 22:53:09 +0530 Subject: [PATCH 2/4] fixed prettier issues --- .../tools/confirmAttachmentUpload/index.ts | 50 ++++++++++++------- .../src/tools/deleteRunAttachment/index.ts | 13 ++--- .../src/tools/deleteTestAttachment/index.ts | 13 ++--- .../src/tools/getPresignedUploadUrl/index.ts | 46 ++++++++++------- .../src/tools/getRunAttachments/index.ts | 15 ++---- .../src/tools/getTestAttachments/index.ts | 13 ++--- 6 files changed, 75 insertions(+), 75 deletions(-) diff --git a/mcp-server/src/tools/confirmAttachmentUpload/index.ts b/mcp-server/src/tools/confirmAttachmentUpload/index.ts index fb77fb1..d2c2d8c 100644 --- a/mcp-server/src/tools/confirmAttachmentUpload/index.ts +++ b/mcp-server/src/tools/confirmAttachmentUpload/index.ts @@ -12,15 +12,32 @@ export default function registerConfirmAttachmentUpload( { projectId: z.number().int().positive().describe('Project ID'), testId: z.number().int().positive().describe('Test ID'), - runId: z.number().int().positive().optional().describe('Run ID (required for actual behavior attachments)'), + runId: z + .number() + .int() + .positive() + .optional() + .describe('Run ID (required for actual behavior attachments)'), storageKey: z.string().min(1).describe('Storage key returned from get-presigned-upload-url'), filename: z.string().min(1).describe('Original filename'), mimeType: z.string().min(1).describe('MIME type'), fileSize: z.number().int().positive().describe('File size in bytes'), description: z.string().optional().describe('Optional description for the attachment'), - attachmentType: z.enum(['expected', 'actual']).describe('Type: expected (test-level) or actual (run-level)'), + attachmentType: z + .enum(['expected', 'actual']) + .describe('Type: expected (test-level) or actual (run-level)'), }, - async ({ projectId, testId, runId, storageKey, filename, mimeType, fileSize, description, attachmentType }) => { + async ({ + projectId, + testId, + runId, + storageKey, + filename, + mimeType, + fileSize, + description, + attachmentType, + }) => { try { // Validate runId for actual attachments if (attachmentType === 'actual' && !runId) { @@ -50,21 +67,17 @@ export default function registerConfirmAttachmentUpload( }), }); - return handleApiResponse( - data, - `confirm attachment upload`, - [ - 'projectId (number, required): Project ID', - 'testId (number, required): Test ID', - 'runId (number, optional): Run ID (required for actual attachments)', - 'storageKey (string, required): Storage key from presigned URL', - 'filename (string, required): Original filename', - 'mimeType (string, required): MIME type', - 'fileSize (number, required): File size in bytes', - 'description (string, optional): Attachment description', - 'attachmentType (string, required): "expected" or "actual"', - ], - ); + return handleApiResponse(data, `confirm attachment upload`, [ + 'projectId (number, required): Project ID', + 'testId (number, required): Test ID', + 'runId (number, optional): Run ID (required for actual attachments)', + 'storageKey (string, required): Storage key from presigned URL', + 'filename (string, required): Original filename', + 'mimeType (string, required): MIME type', + 'fileSize (number, required): File size in bytes', + 'description (string, optional): Attachment description', + 'attachmentType (string, required): "expected" or "actual"', + ]); } catch (error) { return { content: [ @@ -79,4 +92,3 @@ export default function registerConfirmAttachmentUpload( }, ); } - diff --git a/mcp-server/src/tools/deleteRunAttachment/index.ts b/mcp-server/src/tools/deleteRunAttachment/index.ts index 6e74136..17fb691 100644 --- a/mcp-server/src/tools/deleteRunAttachment/index.ts +++ b/mcp-server/src/tools/deleteRunAttachment/index.ts @@ -23,14 +23,10 @@ export default function registerDeleteRunAttachment( }), }); - return handleApiResponse( - data, - `delete run attachment ${attachmentId}`, - [ - 'projectId (number, required): Project ID', - 'attachmentId (number, required): Attachment ID to delete', - ], - ); + return handleApiResponse(data, `delete run attachment ${attachmentId}`, [ + 'projectId (number, required): Project ID', + 'attachmentId (number, required): Attachment ID to delete', + ]); } catch (error) { return { content: [ @@ -45,4 +41,3 @@ export default function registerDeleteRunAttachment( }, ); } - diff --git a/mcp-server/src/tools/deleteTestAttachment/index.ts b/mcp-server/src/tools/deleteTestAttachment/index.ts index fbbea08..cdc3e0b 100644 --- a/mcp-server/src/tools/deleteTestAttachment/index.ts +++ b/mcp-server/src/tools/deleteTestAttachment/index.ts @@ -23,14 +23,10 @@ export default function registerDeleteTestAttachment( }), }); - return handleApiResponse( - data, - `delete test attachment ${attachmentId}`, - [ - 'projectId (number, required): Project ID', - 'attachmentId (number, required): Attachment ID to delete', - ], - ); + return handleApiResponse(data, `delete test attachment ${attachmentId}`, [ + 'projectId (number, required): Project ID', + 'attachmentId (number, required): Attachment ID to delete', + ]); } catch (error) { return { content: [ @@ -45,4 +41,3 @@ export default function registerDeleteTestAttachment( }, ); } - diff --git a/mcp-server/src/tools/getPresignedUploadUrl/index.ts b/mcp-server/src/tools/getPresignedUploadUrl/index.ts index be4eca5..d738e1e 100644 --- a/mcp-server/src/tools/getPresignedUploadUrl/index.ts +++ b/mcp-server/src/tools/getPresignedUploadUrl/index.ts @@ -12,11 +12,21 @@ export default function registerGetPresignedUploadUrl( { projectId: z.number().int().positive().describe('Project ID'), testId: z.number().int().positive().describe('Test ID'), - runId: z.number().int().positive().optional().describe('Run ID (required for actual behavior attachments)'), - filename: z.string().min(1).describe('Original filename with extension (e.g., screenshot.png)'), + runId: z + .number() + .int() + .positive() + .optional() + .describe('Run ID (required for actual behavior attachments)'), + filename: z + .string() + .min(1) + .describe('Original filename with extension (e.g., screenshot.png)'), mimeType: z.string().min(1).describe('MIME type (e.g., image/png, video/mp4)'), fileSize: z.number().int().positive().describe('File size in bytes'), - attachmentType: z.enum(['expected', 'actual']).describe('Type: expected (test-level) or actual (run-level)'), + attachmentType: z + .enum(['expected', 'actual']) + .describe('Type: expected (test-level) or actual (run-level)'), }, async ({ projectId, testId, runId, filename, mimeType, fileSize, attachmentType }) => { try { @@ -47,12 +57,15 @@ export default function registerGetPresignedUploadUrl( }); if (data && typeof data === 'object' && 'data' in data) { - const responseData = data as { data: { uploadUrl: string; storageKey: string; expiresAt: string } }; + const responseData = data as { + data: { uploadUrl: string; storageKey: string; expiresAt: string }; + }; return { content: [ { type: 'text', - text: `✅ Presigned upload URL generated successfully!\n\n` + + text: + `✅ Presigned upload URL generated successfully!\n\n` + `📤 **Upload URL**: ${responseData.data.uploadUrl}\n\n` + `🔑 **Storage Key**: ${responseData.data.storageKey}\n\n` + `⏰ **Expires At**: ${responseData.data.expiresAt}\n\n` + @@ -69,19 +82,15 @@ export default function registerGetPresignedUploadUrl( }; } - return handleApiResponse( - data, - `generate presigned upload URL`, - [ - 'projectId (number, required): Project ID', - 'testId (number, required): Test ID', - 'runId (number, optional): Run ID (required for actual attachments)', - 'filename (string, required): Original filename', - 'mimeType (string, required): MIME type', - 'fileSize (number, required): File size in bytes', - 'attachmentType (string, required): "expected" or "actual"', - ], - ); + return handleApiResponse(data, `generate presigned upload URL`, [ + 'projectId (number, required): Project ID', + 'testId (number, required): Test ID', + 'runId (number, optional): Run ID (required for actual attachments)', + 'filename (string, required): Original filename', + 'mimeType (string, required): MIME type', + 'fileSize (number, required): File size in bytes', + 'attachmentType (string, required): "expected" or "actual"', + ]); } catch (error) { return { content: [ @@ -96,4 +105,3 @@ export default function registerGetPresignedUploadUrl( }, ); } - diff --git a/mcp-server/src/tools/getRunAttachments/index.ts b/mcp-server/src/tools/getRunAttachments/index.ts index 99908a1..1431c46 100644 --- a/mcp-server/src/tools/getRunAttachments/index.ts +++ b/mcp-server/src/tools/getRunAttachments/index.ts @@ -23,15 +23,11 @@ export default function registerGetRunAttachments( }); const data = await makeRequest(`api/v1/run/attachments?${qs.toString()}`); - return handleApiResponse( - data, - `retrieve attachments for test ${testId} in run ${runId}`, - [ - 'projectId (number, required): Project ID', - 'runId (number, required): Run ID', - 'testId (number, required): Test ID', - ], - ); + return handleApiResponse(data, `retrieve attachments for test ${testId} in run ${runId}`, [ + 'projectId (number, required): Project ID', + 'runId (number, required): Run ID', + 'testId (number, required): Test ID', + ]); } catch (error) { return { content: [ @@ -46,4 +42,3 @@ export default function registerGetRunAttachments( }, ); } - diff --git a/mcp-server/src/tools/getTestAttachments/index.ts b/mcp-server/src/tools/getTestAttachments/index.ts index af10f63..6f77503 100644 --- a/mcp-server/src/tools/getTestAttachments/index.ts +++ b/mcp-server/src/tools/getTestAttachments/index.ts @@ -21,14 +21,10 @@ export default function registerGetTestAttachments( }); const data = await makeRequest(`api/v1/test/attachments?${qs.toString()}`); - return handleApiResponse( - data, - `retrieve attachments for test ${testId}`, - [ - 'projectId (number, required): Project ID', - 'testId (number, required): Test ID', - ], - ); + return handleApiResponse(data, `retrieve attachments for test ${testId}`, [ + 'projectId (number, required): Project ID', + 'testId (number, required): Test ID', + ]); } catch (error) { return { content: [ @@ -43,4 +39,3 @@ export default function registerGetTestAttachments( }, ); } - From 342008a08dd688ca16b3643147e7895bfd362d37 Mon Sep 17 00:00:00 2001 From: Mayank Kush Date: Wed, 3 Dec 2025 23:29:43 +0530 Subject: [PATCH 3/4] Added uploads in gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 52bf70c..40300d5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ playground.* coverage/* app/config.json drizzle -app/db/seed/seedTests/*.json \ No newline at end of file +app/db/seed/seedTests/*.json +uploads/ \ No newline at end of file From a92d0e01c98330b6023bc0220e63c32b957f408d Mon Sep 17 00:00:00 2001 From: Mayank Kush Date: Wed, 3 Dec 2025 23:41:11 +0530 Subject: [PATCH 4/4] added cloud deployment guide --- README.md | 27 + website/docs/project/cloud-deployment.mdx | 1295 +++++++++++++++++++++ website/docs/project/setup.mdx | 58 +- website/sidebars.ts | 1 + 4 files changed, 1378 insertions(+), 3 deletions(-) create mode 100644 website/docs/project/cloud-deployment.mdx diff --git a/README.md b/README.md index 68d7aa0..d07d186 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This Test Case Management Tool is designed to address the challenges faced by te - [Detailed Documentation](https://checkmate.dreamhorizon.org/) - [Installation Guide](https://checkmate.dreamhorizon.org/docs/project/setup) +- [Cloud Deployment (AWS/GCP)](https://checkmate.dreamhorizon.org/docs/project/cloud-deployment) - [Product Guide](https://checkmate.dreamhorizon.org/docs/guides/projects) - [API Documentation](https://checkmate.dreamhorizon.org/docs/guides/api/) - [Connect with us on discord](https://discord.gg/wBQXeYAKNc) @@ -105,6 +106,32 @@ cd checkmate 5. App will be started on http://localhost:3000 +### ☁️ Production Deployment (AWS/GCP) + +Deploy Checkmate to production on AWS or GCP. See our [Cloud Deployment Guide](https://checkmate.dreamhorizon.org/docs/project/cloud-deployment) for detailed instructions. + +**Quick Overview:** + +| Platform | Options | +|----------|---------| +| **AWS** | EC2 + Docker, ECS (Fargate), RDS for MySQL | +| **GCP** | Compute Engine, Cloud Run, Cloud SQL | + +**Key Steps:** +1. Configure production Google OAuth credentials +2. Set up managed database (RDS/Cloud SQL) or use Docker MySQL +3. Deploy with Docker Compose or container service +4. Configure SSL with Let's Encrypt or cloud provider certificates +5. Set up monitoring and backups + +```bash +# Example: Deploy on any Linux server with Docker +git clone https://github.com/dream-horizon-org/checkmate.git +cd checkmate +cp .env.example .env # Configure production values +docker-compose up -d --build +``` + ### 📖 API Documentation [Postman](https://documenter.getpostman.com/view/23217307/2sAYXFgwRt) collection of APIs is currently available, comprehensive documentation is in progress. diff --git a/website/docs/project/cloud-deployment.mdx b/website/docs/project/cloud-deployment.mdx new file mode 100644 index 0000000..3ff3ba9 --- /dev/null +++ b/website/docs/project/cloud-deployment.mdx @@ -0,0 +1,1295 @@ +--- +title: Cloud Deployment Guide +description: Deploy Checkmate on AWS, GCP, or other cloud providers +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import { Card, CardGrid } from '@site/src/components/Card'; +import { Steps } from '@site/src/components/Steps'; + +## Overview + +This guide covers deploying Checkmate to production environments on major cloud providers. Choose the deployment method that best fits your infrastructure. + +### Deployment Options + +| Method | Best For | Complexity | +|--------|----------|------------| +| **Docker on VM** | Simple deployments, full control | Low | +| **Container Services** | Auto-scaling, managed infrastructure | Medium | +| **Kubernetes** | Enterprise, multi-region, complex setups | High | + +--- + +## Prerequisites + +Before deploying, ensure you have: + +- ✅ A domain name (e.g., `checkmate.yourdomain.com`) +- ✅ Google OAuth credentials configured for your production domain +- ✅ SSL certificate (or use Let's Encrypt) +- ✅ Cloud provider account (AWS/GCP) + +### Production Google OAuth Setup + +Update your Google OAuth configuration for production: + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) → APIs & Services → Credentials +2. Edit your OAuth 2.0 Client ID +3. Add production URLs: + - **Authorized JavaScript origins:** `https://checkmate.yourdomain.com` + - **Authorized redirect URIs:** `https://checkmate.yourdomain.com/callback` + +--- + +## Environment Variables for Production + +Create a `.env` file with production values: + +```env +# =========================================== +# PRODUCTION ENVIRONMENT CONFIGURATION +# =========================================== + +# Database Connection +# Format: mysql://USER:PASSWORD@HOST:PORT/DATABASE +DB_URL=mysql://checkmate_user:STRONG_PASSWORD@your-db-host:3306/checkmate +DOCKER_DB_URL=mysql://checkmate_user:STRONG_PASSWORD@your-db-host:3306/checkmate + +# MySQL Configuration (for docker-compose) +MYSQL_ROOT_PASSWORD=YOUR_ROOT_PASSWORD +MYSQL_DATABASE=checkmate +MYSQL_USER=checkmate_user +MYSQL_PASSWORD=STRONG_PASSWORD + +# Google OAuth (REQUIRED) +GOOGLE_CLIENT_ID=your_production_client_id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your_production_client_secret + +# Session Security (REQUIRED - generate a strong random string) +# Generate with: openssl rand -base64 64 +SESSION_SECRET=your_very_long_random_session_secret_minimum_32_characters + +# Server Configuration +PORT=3000 +NODE_ENV=production + +# Application URL (used for OAuth callbacks) +APP_URL=https://checkmate.yourdomain.com + +# =========================================== +# FILE STORAGE CONFIGURATION +# =========================================== +# Provider: 'local', 's3', or 'gcs' +STORAGE_PROVIDER=s3 + +# For S3 (AWS or S3-compatible) +STORAGE_BUCKET=your-checkmate-bucket +STORAGE_REGION=us-east-1 +STORAGE_ACCESS_KEY_ID=your-access-key +STORAGE_SECRET_ACCESS_KEY=your-secret-key +# STORAGE_ENDPOINT=https://custom-endpoint.com # For S3-compatible services +# STORAGE_PUBLIC_URL=https://cdn.yourdomain.com # CDN URL + +# For GCS (Google Cloud Storage) +# GCS_PROJECT_ID=your-gcp-project +# GCS_KEY_FILENAME=/path/to/service-account.json +``` + +:::danger Security Warning +- Never commit `.env` files to version control +- Use strong, unique passwords for database +- Generate `SESSION_SECRET` using `openssl rand -base64 64` +- Rotate secrets periodically +::: + +--- + +## File Storage Configuration (S3/GCS) + +Checkmate supports three storage backends for file attachments (test screenshots, videos, etc.): + +| Provider | Best For | Configuration | +|----------|----------|---------------| +| **Local** | Development, single server | Default, no config needed | +| **S3** | AWS deployments, S3-compatible services | AWS credentials + bucket | +| **GCS** | GCP deployments | Service account + bucket | + +### Storage Environment Variables + +Add these to your `.env` file based on your chosen storage provider: + + + + ### Amazon S3 Configuration + + ```env + # Storage Provider + STORAGE_PROVIDER=s3 + + # S3 Bucket Configuration + STORAGE_BUCKET=your-checkmate-bucket + STORAGE_REGION=us-east-1 + + # AWS Credentials + # Option 1: IAM credentials (recommended for EC2 with IAM roles) + # Leave these empty if using IAM roles attached to EC2/ECS + + # Option 2: Access keys (for non-AWS or cross-account) + STORAGE_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE + STORAGE_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + + # Optional: Custom endpoint for S3-compatible services + # STORAGE_ENDPOINT=https://s3.us-east-1.amazonaws.com + + # Optional: CDN URL for serving files (e.g., CloudFront) + # STORAGE_PUBLIC_URL=https://d1234567890.cloudfront.net + ``` + + #### Create S3 Bucket + + ```bash + # Create bucket + aws s3 mb s3://your-checkmate-bucket --region us-east-1 + + # Set bucket policy for private access (recommended) + cat > bucket-policy.json << 'EOF' + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCheckmateAccess", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/checkmate-app-role" + }, + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::your-checkmate-bucket", + "arn:aws:s3:::your-checkmate-bucket/*" + ] + } + ] + } + EOF + + aws s3api put-bucket-policy --bucket your-checkmate-bucket --policy file://bucket-policy.json + ``` + + #### IAM Policy for EC2/ECS (Recommended) + + Instead of access keys, attach this IAM policy to your EC2 instance role or ECS task role: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:GetObjectAcl", + "s3:PutObjectAcl" + ], + "Resource": [ + "arn:aws:s3:::your-checkmate-bucket", + "arn:aws:s3:::your-checkmate-bucket/*" + ] + } + ] + } + ``` + + #### Optional: CloudFront CDN Setup + + For better performance, serve files through CloudFront: + + ```bash + # Create CloudFront distribution pointing to S3 bucket + # Then set STORAGE_PUBLIC_URL to your CloudFront domain + STORAGE_PUBLIC_URL=https://d1234567890.cloudfront.net + ``` + + + + + ### Google Cloud Storage Configuration + + ```env + # Storage Provider + STORAGE_PROVIDER=gcs + + # GCS Bucket Configuration + STORAGE_BUCKET=your-checkmate-bucket + + # GCP Project + GCS_PROJECT_ID=your-gcp-project-id + + # Authentication - Option 1: Service Account Key File + GCS_KEY_FILENAME=/app/credentials/gcs-service-account.json + + # Authentication - Option 2: Workload Identity (GKE/Cloud Run) + # Leave GCS_KEY_FILENAME empty - uses default credentials + + # Optional: CDN URL (e.g., Cloud CDN) + # STORAGE_PUBLIC_URL=https://your-cdn-domain.com + ``` + + #### Create GCS Bucket + + ```bash + # Create bucket + gcloud storage buckets create gs://your-checkmate-bucket \ + --location=us-central1 \ + --uniform-bucket-level-access + + # Set lifecycle policy (optional - auto-delete old files) + cat > lifecycle.json << 'EOF' + { + "lifecycle": { + "rule": [ + { + "action": {"type": "Delete"}, + "condition": {"age": 365} + } + ] + } + } + EOF + + gcloud storage buckets update gs://your-checkmate-bucket --lifecycle-file=lifecycle.json + ``` + + #### Create Service Account + + ```bash + # Create service account + gcloud iam service-accounts create checkmate-storage \ + --display-name="Checkmate Storage Service Account" + + # Grant storage permissions + gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ + --member="serviceAccount:checkmate-storage@YOUR_PROJECT_ID.iam.gserviceaccount.com" \ + --role="roles/storage.objectAdmin" + + # Create and download key file + gcloud iam service-accounts keys create gcs-service-account.json \ + --iam-account=checkmate-storage@YOUR_PROJECT_ID.iam.gserviceaccount.com + ``` + + #### Mount Key File in Docker + + Add to `docker-compose.yml`: + + ```yaml + services: + checkmate-app: + # ... other config + volumes: + - ./gcs-service-account.json:/app/credentials/gcs-service-account.json:ro + environment: + GCS_KEY_FILENAME: /app/credentials/gcs-service-account.json + ``` + + #### For Cloud Run (Workload Identity) + + No key file needed - use Workload Identity: + + ```bash + # Grant storage access to Cloud Run service account + gcloud run services update checkmate \ + --service-account=checkmate-storage@YOUR_PROJECT_ID.iam.gserviceaccount.com + ``` + + + + + ### S3-Compatible Services + + For MinIO, Cloudflare R2, DigitalOcean Spaces, or other S3-compatible services: + + ```env + # Storage Provider + STORAGE_PROVIDER=s3 + + # Bucket Configuration + STORAGE_BUCKET=checkmate + STORAGE_REGION=auto # or your region + + # S3-Compatible Endpoint + STORAGE_ENDPOINT=https://your-s3-compatible-endpoint.com + + # Credentials + STORAGE_ACCESS_KEY_ID=your-access-key + STORAGE_SECRET_ACCESS_KEY=your-secret-key + + # Public URL (if different from endpoint) + STORAGE_PUBLIC_URL=https://your-public-url.com/checkmate + ``` + + #### Cloudflare R2 Example + + ```env + STORAGE_PROVIDER=s3 + STORAGE_BUCKET=checkmate-attachments + STORAGE_REGION=auto + STORAGE_ENDPOINT=https://ACCOUNT_ID.r2.cloudflarestorage.com + STORAGE_ACCESS_KEY_ID=your-r2-access-key + STORAGE_SECRET_ACCESS_KEY=your-r2-secret-key + STORAGE_PUBLIC_URL=https://your-r2-public-domain.com + ``` + + #### MinIO Example (Self-Hosted) + + ```env + STORAGE_PROVIDER=s3 + STORAGE_BUCKET=checkmate + STORAGE_REGION=us-east-1 + STORAGE_ENDPOINT=http://minio:9000 + STORAGE_ACCESS_KEY_ID=minioadmin + STORAGE_SECRET_ACCESS_KEY=minioadmin + STORAGE_PUBLIC_URL=http://localhost:9000/checkmate + ``` + + Add MinIO to `docker-compose.yml`: + + ```yaml + services: + minio: + image: minio/minio + container_name: checkmate-minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio-data:/data + networks: + - checkmate-network + + volumes: + minio-data: + ``` + + + + + ### Local File Storage + + For development or single-server deployments: + + ```env + # Default - no configuration needed + STORAGE_PROVIDER=local + + # Optional: Custom storage path (default: ./uploads/attachments) + STORAGE_LOCAL_PATH=/app/uploads/attachments + + # Optional: Custom public URL + STORAGE_PUBLIC_URL=/api/v1/attachments/serve + ``` + + #### Persist Local Storage in Docker + + Add volume mount to `docker-compose.yml`: + + ```yaml + services: + checkmate-app: + # ... other config + volumes: + - checkmate-uploads:/app/uploads + + volumes: + checkmate-uploads: + name: checkmate-uploads + ``` + + :::warning Local Storage Limitations + - Not suitable for multi-instance deployments + - Files lost if container is rebuilt without volume + - No built-in backup or redundancy + - Recommend S3/GCS for production + ::: + + + + +### Install Required Packages + +For cloud storage providers, install the required SDK: + +```bash +# For AWS S3 +yarn add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner + +# For Google Cloud Storage +yarn add @google-cloud/storage +``` + +:::tip +If you're using Docker, the SDKs are already included in the image. Just set the environment variables. +::: + +--- + +## AWS Deployment + + + + ### Deploy on Amazon EC2 + + Best for: Simple deployments with full control over the server. + + + + 1. **Launch EC2 Instance** + + - Go to AWS Console → EC2 → Launch Instance + - Select **Ubuntu 22.04 LTS** (or Amazon Linux 2023) + - Instance type: **t3.medium** (minimum for production) + - Storage: **30GB+ SSD** + - Security Group rules: + ``` + SSH (22) - Your IP only + HTTP (80) - 0.0.0.0/0 + HTTPS (443) - 0.0.0.0/0 + MySQL (3306) - VPC only (if using external DB) + ``` + + 2. **Connect to Instance** + ```bash + ssh -i your-key.pem ubuntu@your-ec2-public-ip + ``` + + 3. **Install Docker & Docker Compose** + ```bash + # Update system + sudo apt update && sudo apt upgrade -y + + # Install Docker + curl -fsSL https://get.docker.com | sudo sh + sudo usermod -aG docker $USER + + # Install Docker Compose + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + + # Log out and back in for group changes + exit + ``` + + 4. **Clone and Configure Checkmate** + ```bash + # Reconnect after logout + ssh -i your-key.pem ubuntu@your-ec2-public-ip + + # Clone repository + git clone https://github.com/dream-horizon-org/checkmate.git + cd checkmate + + # Create production .env file + nano .env + # (paste your production environment variables) + ``` + + 5. **Start Checkmate** + ```bash + # Build and start all services + docker-compose up -d --build + + # Check status + docker-compose ps + + # View logs + docker-compose logs -f checkmate-app + ``` + + 6. **Setup Nginx Reverse Proxy with SSL** + ```bash + # Install Nginx and Certbot + sudo apt install nginx certbot python3-certbot-nginx -y + + # Create Nginx config + sudo nano /etc/nginx/sites-available/checkmate + ``` + + Add this configuration: + ```nginx + server { + listen 80; + server_name checkmate.yourdomain.com; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + } + ``` + + Enable the site and get SSL: + ```bash + # Enable site + sudo ln -s /etc/nginx/sites-available/checkmate /etc/nginx/sites-enabled/ + sudo nginx -t + sudo systemctl reload nginx + + # Get SSL certificate + sudo certbot --nginx -d checkmate.yourdomain.com + + # Auto-renewal is configured automatically + ``` + + 7. **Configure DNS** + + Point your domain to the EC2 public IP: + - Add an **A record**: `checkmate.yourdomain.com` → `EC2-PUBLIC-IP` + + + + :::tip Auto-restart on Reboot + Docker containers will restart automatically. To ensure Docker starts on boot: + ```bash + sudo systemctl enable docker + ``` + ::: + + + + + ### Using Amazon RDS for MySQL + + For production workloads, use Amazon RDS for managed database with backups and high availability. + + + + 1. **Create RDS Instance** + + - Go to AWS Console → RDS → Create Database + - Engine: **MySQL 8.0** + - Template: **Production** (or Free tier for testing) + - Instance class: **db.t3.micro** (dev) or **db.t3.medium** (prod) + - Storage: **20GB+ SSD** with autoscaling + - Connectivity: + - VPC: Same as EC2 + - Public access: **No** (access via EC2 only) + - Security group: Allow MySQL (3306) from EC2 security group + + 2. **Create Database and User** + + Connect from EC2: + ```bash + # Install MySQL client + sudo apt install mysql-client -y + + # Connect to RDS + mysql -h your-rds-endpoint.region.rds.amazonaws.com -u admin -p + ``` + + Create database and user: + ```sql + CREATE DATABASE checkmate; + CREATE USER 'checkmate_user'@'%' IDENTIFIED BY 'STRONG_PASSWORD'; + GRANT ALL PRIVILEGES ON checkmate.* TO 'checkmate_user'@'%'; + FLUSH PRIVILEGES; + EXIT; + ``` + + 3. **Update Environment Variables** + ```env + # Use RDS endpoint instead of localhost + DB_URL=mysql://checkmate_user:STRONG_PASSWORD@your-rds-endpoint.region.rds.amazonaws.com:3306/checkmate + DOCKER_DB_URL=mysql://checkmate_user:STRONG_PASSWORD@your-rds-endpoint.region.rds.amazonaws.com:3306/checkmate + ``` + + 4. **Modify docker-compose.yml** + + Remove or comment out the database services since you're using RDS: + ```yaml + services: + # Comment out or remove these services: + # checkmate-db: + # ... + # db_seeder: + # ... + + checkmate-app: + build: + context: . + dockerfile: ./dockerfile + container_name: checkmate-app + env_file: + - .env + environment: + DB_URL: ${DB_URL} + NODE_ENV: production + ports: + - 3000:3000 + restart: always + ``` + + 5. **Seed Database Manually** + ```bash + # From EC2, seed the RDS database + mysql -h your-rds-endpoint.region.rds.amazonaws.com -u checkmate_user -p checkmate < seedData.sql + ``` + + 6. **Start Application** + ```bash + docker-compose up -d checkmate-app + ``` + + + + + + + ### Deploy on Amazon ECS + + For auto-scaling and managed container orchestration. + + + + 1. **Push Docker Image to ECR** + ```bash + # Create ECR repository + aws ecr create-repository --repository-name checkmate + + # Login to ECR + aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com + + # Build and push + docker build -t checkmate . + docker tag checkmate:latest YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/checkmate:latest + docker push YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/checkmate:latest + ``` + + 2. **Create ECS Cluster** + + - Go to ECS Console → Create Cluster + - Select **AWS Fargate** (serverless) + - Name: `checkmate-cluster` + + 3. **Create Task Definition** + + Create `task-definition.json`: + ```json + { + "family": "checkmate", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "512", + "memory": "1024", + "executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT:role/ecsTaskExecutionRole", + "containerDefinitions": [ + { + "name": "checkmate-app", + "image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/checkmate:latest", + "portMappings": [ + { + "containerPort": 3000, + "protocol": "tcp" + } + ], + "environment": [ + {"name": "NODE_ENV", "value": "production"}, + {"name": "PORT", "value": "3000"} + ], + "secrets": [ + { + "name": "DB_URL", + "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT:secret:checkmate/db-url" + }, + { + "name": "GOOGLE_CLIENT_ID", + "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT:secret:checkmate/google-client-id" + }, + { + "name": "GOOGLE_CLIENT_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT:secret:checkmate/google-client-secret" + }, + { + "name": "SESSION_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT:secret:checkmate/session-secret" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/checkmate", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "ecs" + } + } + } + ] + } + ``` + + Register task definition: + ```bash + aws ecs register-task-definition --cli-input-json file://task-definition.json + ``` + + 4. **Create Service with Load Balancer** + + - Create Application Load Balancer (ALB) + - Create Target Group pointing to port 3000 + - Create ECS Service: + - Launch type: Fargate + - Task definition: checkmate + - Load balancer: Your ALB + - Desired count: 2 (for high availability) + + 5. **Configure Auto Scaling** + + Set up target tracking scaling based on CPU/Memory utilization. + + + + + + +--- + +## GCP Deployment + + + + ### Deploy on Google Compute Engine + + + + 1. **Create VM Instance** + + ```bash + # Using gcloud CLI + gcloud compute instances create checkmate-server \ + --zone=us-central1-a \ + --machine-type=e2-medium \ + --image-family=ubuntu-2204-lts \ + --image-project=ubuntu-os-cloud \ + --boot-disk-size=30GB \ + --tags=http-server,https-server + + # Create firewall rules + gcloud compute firewall-rules create allow-http \ + --allow tcp:80 --target-tags=http-server + + gcloud compute firewall-rules create allow-https \ + --allow tcp:443 --target-tags=https-server + ``` + + Or use GCP Console: + - Go to Compute Engine → VM Instances → Create Instance + - Machine type: **e2-medium** + - Boot disk: **Ubuntu 22.04 LTS**, 30GB SSD + - Firewall: Allow HTTP and HTTPS traffic + + 2. **Connect to VM** + ```bash + gcloud compute ssh checkmate-server --zone=us-central1-a + ``` + + 3. **Install Docker** + ```bash + # Update and install Docker + sudo apt update && sudo apt upgrade -y + curl -fsSL https://get.docker.com | sudo sh + sudo usermod -aG docker $USER + + # Install Docker Compose + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + + # Logout and reconnect + exit + ``` + + 4. **Deploy Checkmate** + ```bash + # Reconnect + gcloud compute ssh checkmate-server --zone=us-central1-a + + # Clone and configure + git clone https://github.com/dream-horizon-org/checkmate.git + cd checkmate + nano .env # Add production environment variables + + # Start services + docker-compose up -d --build + ``` + + 5. **Setup Nginx with SSL** + ```bash + # Install Nginx and Certbot + sudo apt install nginx certbot python3-certbot-nginx -y + + # Configure Nginx (same as AWS EC2 guide) + sudo nano /etc/nginx/sites-available/checkmate + # Add the Nginx configuration from AWS guide + + sudo ln -s /etc/nginx/sites-available/checkmate /etc/nginx/sites-enabled/ + sudo nginx -t && sudo systemctl reload nginx + + # Get SSL certificate + sudo certbot --nginx -d checkmate.yourdomain.com + ``` + + 6. **Reserve Static IP** + ```bash + # Reserve external IP to prevent changes on restart + gcloud compute addresses create checkmate-ip --region=us-central1 + + # Assign to instance + gcloud compute instances delete-access-config checkmate-server \ + --zone=us-central1-a --access-config-name="external-nat" + + gcloud compute instances add-access-config checkmate-server \ + --zone=us-central1-a \ + --address=$(gcloud compute addresses describe checkmate-ip --region=us-central1 --format='value(address)') + ``` + + + + + + + ### Using Cloud SQL for MySQL + + + + 1. **Create Cloud SQL Instance** + ```bash + gcloud sql instances create checkmate-db \ + --database-version=MYSQL_8_0 \ + --tier=db-f1-micro \ + --region=us-central1 \ + --root-password=YOUR_ROOT_PASSWORD \ + --storage-size=10GB \ + --storage-auto-increase + ``` + + 2. **Create Database and User** + ```bash + # Create database + gcloud sql databases create checkmate --instance=checkmate-db + + # Create user + gcloud sql users create checkmate_user \ + --instance=checkmate-db \ + --password=STRONG_PASSWORD + ``` + + 3. **Configure Private IP (Recommended)** + + For secure connection from Compute Engine: + ```bash + # Enable private IP + gcloud sql instances patch checkmate-db \ + --network=default \ + --no-assign-ip + ``` + + 4. **Update Environment Variables** + ```env + # For private IP connection + DB_URL=mysql://checkmate_user:STRONG_PASSWORD@CLOUD_SQL_PRIVATE_IP:3306/checkmate + + # Or for public IP with SSL (not recommended) + # DB_URL=mysql://checkmate_user:STRONG_PASSWORD@CLOUD_SQL_PUBLIC_IP:3306/checkmate?ssl=true + ``` + + 5. **Seed Database** + ```bash + # Connect using Cloud SQL Proxy for seeding + # Download proxy: https://cloud.google.com/sql/docs/mysql/connect-admin-proxy + + ./cloud_sql_proxy -instances=PROJECT:REGION:checkmate-db=tcp:3307 & + mysql -h 127.0.0.1 -P 3307 -u checkmate_user -p checkmate < seedData.sql + ``` + + + + + + + ### Deploy on Cloud Run (Serverless) + + Best for: Auto-scaling, pay-per-use, minimal ops. + + + + 1. **Build and Push to Container Registry** + ```bash + # Enable required APIs + gcloud services enable containerregistry.googleapis.com run.googleapis.com + + # Build and push + gcloud builds submit --tag gcr.io/YOUR_PROJECT/checkmate + ``` + + 2. **Deploy to Cloud Run** + ```bash + gcloud run deploy checkmate \ + --image gcr.io/YOUR_PROJECT/checkmate \ + --platform managed \ + --region us-central1 \ + --allow-unauthenticated \ + --port 3000 \ + --memory 1Gi \ + --cpu 1 \ + --min-instances 1 \ + --max-instances 10 \ + --set-env-vars "NODE_ENV=production" \ + --set-secrets "DB_URL=checkmate-db-url:latest,GOOGLE_CLIENT_ID=checkmate-google-client-id:latest,GOOGLE_CLIENT_SECRET=checkmate-google-client-secret:latest,SESSION_SECRET=checkmate-session-secret:latest" + ``` + + 3. **Store Secrets in Secret Manager** + ```bash + # Create secrets + echo -n "mysql://user:pass@host:3306/checkmate" | \ + gcloud secrets create checkmate-db-url --data-file=- + + echo -n "your-google-client-id" | \ + gcloud secrets create checkmate-google-client-id --data-file=- + + echo -n "your-google-client-secret" | \ + gcloud secrets create checkmate-google-client-secret --data-file=- + + echo -n "your-session-secret" | \ + gcloud secrets create checkmate-session-secret --data-file=- + + # Grant Cloud Run access to secrets + gcloud secrets add-iam-policy-binding checkmate-db-url \ + --member="serviceAccount:YOUR_PROJECT_NUMBER-compute@developer.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" + # Repeat for other secrets + ``` + + 4. **Connect Cloud SQL** + + For Cloud Run to Cloud SQL connection: + ```bash + gcloud run services update checkmate \ + --add-cloudsql-instances YOUR_PROJECT:us-central1:checkmate-db \ + --region us-central1 + ``` + + Update DB_URL to use socket: + ``` + mysql://user:pass@localhost/checkmate?socket=/cloudsql/PROJECT:REGION:INSTANCE + ``` + + 5. **Map Custom Domain** + + - Go to Cloud Run Console → Your Service → Manage Custom Domains + - Add your domain and verify ownership + - SSL is automatic + + + + + + +--- + +## Production Checklist + +### Security + +- [ ] **SSL/TLS enabled** - All traffic over HTTPS +- [ ] **Strong passwords** - Database and session secrets +- [ ] **Firewall configured** - Only necessary ports open +- [ ] **OAuth configured** - Production URLs in Google Console +- [ ] **Secrets management** - Use cloud provider's secret manager +- [ ] **Regular updates** - Keep system and dependencies updated +- [ ] **Storage credentials secured** - IAM roles preferred over access keys + +### Performance + +- [ ] **Database optimization** - Indexes, connection pooling +- [ ] **Memory allocation** - Adequate RAM for Node.js +- [ ] **CDN for static assets** - (Optional) CloudFront or Cloud CDN +- [ ] **CDN for attachments** - S3/GCS with CloudFront/Cloud CDN +- [ ] **Monitoring setup** - CloudWatch, Stackdriver, or similar + +### Reliability + +- [ ] **Automated backups** - Database and configuration +- [ ] **Health checks** - Load balancer health endpoints +- [ ] **Auto-restart** - Container restart policies +- [ ] **Logging** - Centralized log collection +- [ ] **Alerting** - Set up notifications for errors +- [ ] **File storage configured** - S3/GCS for production (not local) + +### Maintenance + +- [ ] **Update strategy** - Plan for rolling updates +- [ ] **Backup restoration tested** - Verify backup recovery works +- [ ] **Documentation** - Runbooks for common operations +- [ ] **Access control** - Limit SSH/admin access +- [ ] **Storage lifecycle policies** - Auto-cleanup of old files + +--- + +## Monitoring & Logging + +### Application Logs + +```bash +# Docker logs +docker-compose logs -f checkmate-app + +# Follow logs with timestamps +docker-compose logs -f --timestamps checkmate-app +``` + +### Health Check Endpoint + +Checkmate exposes a health endpoint at: +``` +GET /api/v1/user/details +``` + +Configure your load balancer to use this for health checks. + +### Recommended Monitoring Tools + +| Provider | Tool | Purpose | +|----------|------|---------| +| AWS | CloudWatch | Logs, metrics, alarms | +| AWS | X-Ray | Distributed tracing | +| GCP | Cloud Logging | Centralized logs | +| GCP | Cloud Monitoring | Metrics and alerting | +| Any | Datadog | Full observability | +| Any | Grafana + Prometheus | Open-source monitoring | + +--- + +## Backup & Recovery + +### Database Backups + + + + ```bash + # Enable automated backups (done during RDS creation) + # Manual snapshot + aws rds create-db-snapshot \ + --db-instance-identifier checkmate-db \ + --db-snapshot-identifier checkmate-backup-$(date +%Y%m%d) + ``` + + + + ```bash + # Automated backups are enabled by default + # Manual backup + gcloud sql backups create --instance=checkmate-db + + # List backups + gcloud sql backups list --instance=checkmate-db + ``` + + + + ```bash + # Backup + docker exec checkmate-db mysqldump -u root -pROOT_PASSWORD checkmate > backup_$(date +%Y%m%d).sql + + # Restore + docker exec -i checkmate-db mysql -u root -pROOT_PASSWORD checkmate < backup_20240115.sql + ``` + + + +### File Storage Backups + + + + ```bash + # Enable S3 versioning (recommended) + aws s3api put-bucket-versioning \ + --bucket your-checkmate-bucket \ + --versioning-configuration Status=Enabled + + # Cross-region replication for disaster recovery + aws s3api put-bucket-replication \ + --bucket your-checkmate-bucket \ + --replication-configuration file://replication-config.json + + # Sync to backup bucket + aws s3 sync s3://your-checkmate-bucket s3://your-backup-bucket + + # Download backup locally + aws s3 sync s3://your-checkmate-bucket ./s3-backup/ + ``` + + + + ```bash + # Enable object versioning + gcloud storage buckets update gs://your-checkmate-bucket --versioning + + # Copy to backup bucket + gcloud storage cp -r gs://your-checkmate-bucket/* gs://your-backup-bucket/ + + # Download backup locally + gcloud storage cp -r gs://your-checkmate-bucket ./gcs-backup/ + + # Set up Cloud Storage Transfer for scheduled backups + gcloud transfer jobs create \ + gs://your-checkmate-bucket \ + gs://your-backup-bucket \ + --schedule-repeats-every=1d + ``` + + + + ```bash + # Backup uploads directory + tar -czvf uploads_backup_$(date +%Y%m%d).tar.gz uploads/ + + # Restore uploads + tar -xzvf uploads_backup_20240115.tar.gz + + # Sync to remote backup + rsync -avz uploads/ backup-server:/path/to/backups/uploads/ + ``` + + + +### Application Backup + +```bash +# Backup environment and config +cp .env .env.backup +cp docker-compose.yml docker-compose.yml.backup + +# Backup uploads directory (for local storage) +tar -czvf uploads_backup_$(date +%Y%m%d).tar.gz uploads/ +``` + +--- + +## Troubleshooting + +### Container won't start + +```bash +# Check container logs +docker-compose logs checkmate-app + +# Check container status +docker-compose ps + +# Restart containers +docker-compose restart + +# Rebuild from scratch +docker-compose down +docker-compose up -d --build +``` + +### Database connection issues + +```bash +# Test database connectivity +docker exec -it checkmate-app sh -c "nc -zv checkmate-db 3306" + +# Check database logs +docker-compose logs checkmate-db + +# Verify environment variables +docker exec checkmate-app env | grep DB_URL +``` + +### SSL certificate issues + +```bash +# Check certificate status +sudo certbot certificates + +# Renew certificate manually +sudo certbot renew --dry-run + +# Force renewal +sudo certbot renew --force-renewal +``` + +### High memory usage + +```bash +# Check container stats +docker stats + +# Increase memory limit in docker-compose.yml +services: + checkmate-app: + deploy: + resources: + limits: + memory: 2G +``` + +--- + +## Next Steps + + + + Return to basic setup + + [Setup Guide →](/docs/project/setup) + + + + Configure integrations + + [API Docs →](/docs/guides/api/) + + + + Deploy MCP server + + [MCP Docker →](/docs/project/mcp-docker) + + + + Understand the system + + [Architecture →](/docs/tech/architecture) + + + +--- + +## Additional Resources + +- **AWS Documentation:** [docs.aws.amazon.com](https://docs.aws.amazon.com/) +- **GCP Documentation:** [cloud.google.com/docs](https://cloud.google.com/docs) +- **Docker Documentation:** [docs.docker.com](https://docs.docker.com/) +- **Nginx Documentation:** [nginx.org/en/docs](https://nginx.org/en/docs/) +- **Let's Encrypt:** [letsencrypt.org/docs](https://letsencrypt.org/docs/) +- **Discord Community:** [Join Discord](https://discord.gg/wBQXeYAKNc) + diff --git a/website/docs/project/setup.mdx b/website/docs/project/setup.mdx index a202299..378f501 100644 --- a/website/docs/project/setup.mdx +++ b/website/docs/project/setup.mdx @@ -476,12 +476,64 @@ NODE_ENV=development LOG_LEVEL=info ``` +### File Storage Variables (Production) + +For production deployments, configure cloud storage for file attachments: + +```env +# Storage provider: 'local' (default), 's3', or 'gcs' +STORAGE_PROVIDER=s3 + +# AWS S3 Configuration +STORAGE_BUCKET=your-bucket-name +STORAGE_REGION=us-east-1 +STORAGE_ACCESS_KEY_ID=your-access-key +STORAGE_SECRET_ACCESS_KEY=your-secret-key + +# Google Cloud Storage Configuration +# GCS_PROJECT_ID=your-project-id +# GCS_KEY_FILENAME=/path/to/service-account.json + +# Optional: CDN URL for serving files +# STORAGE_PUBLIC_URL=https://cdn.yourdomain.com +``` + +See the [Cloud Deployment Guide](/docs/project/cloud-deployment#file-storage-configuration-s3gcs) for detailed storage setup instructions. + :::tip **Generate secure secrets:** Use `openssl rand -base64 32` to generate a strong `SESSION_SECRET`. ::: --- +## Cloud Deployment + +Ready to deploy Checkmate to production? We have detailed guides for major cloud providers: + + + + Deploy on EC2, ECS, or with RDS + + [AWS Guide →](/docs/project/cloud-deployment#aws-deployment) + + + + Deploy on Compute Engine, Cloud Run, or Cloud SQL + + [GCP Guide →](/docs/project/cloud-deployment#gcp-deployment) + + + +The [Cloud Deployment Guide](/docs/project/cloud-deployment) covers: +- ✅ Production environment configuration +- ✅ SSL/TLS setup with Let's Encrypt +- ✅ Managed database setup (RDS/Cloud SQL) +- ✅ Container orchestration (ECS/Cloud Run) +- ✅ Monitoring and logging +- ✅ Backup and recovery strategies + +--- + ## Next Steps @@ -491,10 +543,10 @@ LOG_LEVEL=info [Get Started →](/docs/guides/projects) - - Understand how Checkmate works + + Deploy to AWS or GCP - [Read Docs →](/docs/tech/architecture) + [Deploy →](/docs/project/cloud-deployment) diff --git a/website/sidebars.ts b/website/sidebars.ts index e84efea..f6c097e 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -4,6 +4,7 @@ const sidebars: SidebarsConfig = { docsSidebar: [ 'project/introduction', 'project/setup', + 'project/cloud-deployment', { type: 'category', label: 'User Guide',