diff --git a/.gitignore b/.gitignore index f9ddfb236..e11ebeace 100644 --- a/.gitignore +++ b/.gitignore @@ -56,8 +56,9 @@ tailwind.css # Trigger .trigger -# Playground (for local testing) -/playground +# Playground +playground/ +.playground/ .venv # Ignore lock files to prevent conflicts between different package managers @@ -76,8 +77,6 @@ playwright-report/ playwright/.cache/ debug-setup-page.png -.playground/ - packages/*/dist # Generated Prisma Client diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index c3ded24c8..088345ef9 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -52,7 +52,7 @@ const config: NextConfig = { }; if (process.env.VERCEL !== '1') { - config.output = 'standalone'; // Required for Docker builds + config.output = 'standalone'; } export default config; diff --git a/apps/app/src/actions/files/upload-file.ts b/apps/app/src/actions/files/upload-file.ts index 6b5e3d377..0aea6a67a 100644 --- a/apps/app/src/actions/files/upload-file.ts +++ b/apps/app/src/actions/files/upload-file.ts @@ -1,13 +1,13 @@ 'use server'; import { BUCKET_NAME, s3Client } from '@/app/s3'; -import { auth } from '@/utils/auth'; +import { logger } from '@/utils/logger'; import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { AttachmentEntityType, AttachmentType, db } from '@db'; import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; import { z } from 'zod'; +import { authActionClient } from '../safe-action'; function mapFileTypeToAttachmentType(fileType: string): AttachmentType { const type = fileType.split('/')[0]; @@ -34,94 +34,79 @@ const uploadAttachmentSchema = z.object({ pathToRevalidate: z.string().optional(), }); -export const uploadFile = async (input: z.infer) => { - const { fileName, fileType, fileData, entityId, entityType, pathToRevalidate } = input; - const session = await auth.api.getSession({ headers: await headers() }); - const organizationId = session?.session.activeOrganizationId; +export const uploadFile = authActionClient + .inputSchema(uploadAttachmentSchema) + .metadata({ + name: 'uploadFile', + track: { + event: 'File Uploaded', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { fileName, fileType, fileData, entityId, entityType, pathToRevalidate } = parsedInput; + const { session } = ctx; + const organizationId = session.activeOrganizationId; + + logger.info(`[uploadFile] Starting upload for ${fileName} in org ${organizationId}`); - if (!organizationId) { - return { - success: false, - error: 'Not authorized - no organization found', - data: null, - }; - } - - try { const fileBuffer = Buffer.from(fileData, 'base64'); - const MAX_FILE_SIZE_MB = 5; + const MAX_FILE_SIZE_MB = 10; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { - return { - success: false, - error: `File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, - data: null, - }; + throw new Error(`File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`); } const timestamp = Date.now(); const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); const key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${sanitizedFileName}`; - const putCommand = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: fileBuffer, - ContentType: fileType, - }); - - await s3Client.send(putCommand); - - console.log('Creating attachment...'); - console.log({ - name: fileName, - url: key, - type: mapFileTypeToAttachmentType(fileType), - entityId: entityId, - entityType: entityType, - organizationId: organizationId, - }); - - const attachment = await db.attachment.create({ - data: { - name: fileName, - url: key, - type: mapFileTypeToAttachmentType(fileType), - entityId: entityId, - entityType: entityType, - organizationId: organizationId, - }, - }); - - const getCommand = new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - }); + try { + logger.info(`[uploadFile] Uploading to S3 with key: ${key}`); + const putCommand = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: fileBuffer, + ContentType: fileType, + }); + await s3Client.send(putCommand); + logger.info(`[uploadFile] S3 upload successful for key: ${key}`); + + logger.info(`[uploadFile] Creating attachment record in DB for key: ${key}`); + const attachment = await db.attachment.create({ + data: { + name: fileName, + url: key, + type: mapFileTypeToAttachmentType(fileType), + entityId: entityId, + entityType: entityType, + organizationId: organizationId, + }, + }); + logger.info(`[uploadFile] DB record created with id: ${attachment.id}`); + + logger.info(`[uploadFile] Generating signed URL for key: ${key}`); + const getCommand = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }); + const signedUrl = await getSignedUrl(s3Client, getCommand, { + expiresIn: 900, + }); + logger.info(`[uploadFile] Signed URL generated for key: ${key}`); + + if (pathToRevalidate) { + revalidatePath(pathToRevalidate); + } - const signedUrl = await getSignedUrl(s3Client, getCommand, { - expiresIn: 900, - }); - - if (pathToRevalidate) { - revalidatePath(pathToRevalidate); - } - - return { - success: true, - data: { + return { ...attachment, signedUrl, - }, - error: null, - } as const; - } catch (error) { - console.error('Upload file action error:', error); - - return { - success: false, - error: 'Failed to process file upload.', - data: null, - } as const; - } -}; + }; + } catch (error) { + logger.error(`[uploadFile] Error during upload process for key ${key}:`, error); + // Re-throw the error to be handled by the safe action client + throw error; + } + }); diff --git a/apps/app/src/actions/safe-action.ts b/apps/app/src/actions/safe-action.ts index 44e935b67..3814b4f65 100644 --- a/apps/app/src/actions/safe-action.ts +++ b/apps/app/src/actions/safe-action.ts @@ -22,7 +22,7 @@ if (env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) { export const actionClientWithMeta = createSafeActionClient({ handleServerError(e) { // Log the error for debugging - logger('Server error:', e); + logger.error('Server error:', e); // Throw the error instead of returning it if (e instanceof Error) { @@ -68,16 +68,13 @@ export const authActionClient = actionClientWithMeta }, }); - if (process.env.NODE_ENV === 'development') { - logger('Input ->', JSON.stringify(clientInput, null, 2)); - logger('Result ->', JSON.stringify(result.data, null, 2)); + const { fileData: _, ...inputForLog } = clientInput as any; + logger.info('Input ->', JSON.stringify(inputForLog, null, 2)); + logger.info('Result ->', JSON.stringify(result.data, null, 2)); - // Also log validation errors if they exist - if (result.validationErrors) { - logger('Validation Errors ->', JSON.stringify(result.validationErrors, null, 2)); - } - - return result; + // Also log validation errors if they exist + if (result.validationErrors) { + logger.warn('Validation Errors ->', JSON.stringify(result.validationErrors, null, 2)); } return result; @@ -154,13 +151,15 @@ export const authActionClient = actionClientWithMeta throw new Error('Member not found'); } + const { fileData: _, ...inputForAuditLog } = clientInput as any; + const data = { userId: session.user.id, email: session.user.email, name: session.user.name, organizationId: session.session.activeOrganizationId, action: metadata.name, - input: clientInput, + input: inputForAuditLog, ipAddress: headersList.get('x-forwarded-for') || null, userAgent: headersList.get('user-agent') || null, }; @@ -212,7 +211,7 @@ export const authActionClient = actionClientWithMeta }, }); } catch (error) { - logger('Audit log error:', error); + logger.error('Audit log error:', error); } // Add revalidation logic based on the cursor rules @@ -273,16 +272,13 @@ export const authActionClientWithoutOrg = actionClientWithMeta }, }); - if (process.env.NODE_ENV === 'development') { - logger('Input ->', JSON.stringify(clientInput, null, 2)); - logger('Result ->', JSON.stringify(result.data, null, 2)); - - // Also log validation errors if they exist - if (result.validationErrors) { - logger('Validation Errors ->', JSON.stringify(result.validationErrors, null, 2)); - } + const { fileData: _, ...inputForLog } = clientInput as any; + logger.info('Input ->', JSON.stringify(inputForLog, null, 2)); + logger.info('Result ->', JSON.stringify(result.data, null, 2)); - return result; + // Also log validation errors if they exist + if (result.validationErrors) { + logger.warn('Validation Errors ->', JSON.stringify(result.validationErrors, null, 2)); } return result; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx index 226df8ea6..436b4797c 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx @@ -7,6 +7,7 @@ import { Textarea } from '@comp/ui/textarea'; import type { Attachment } from '@db'; import { AttachmentEntityType } from '@db'; import { Loader2, Paperclip, Plus } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; import { useRouter } from 'next/navigation'; import type React from 'react'; import { useCallback, useRef, useState } from 'react'; @@ -37,76 +38,60 @@ export function TaskBody({ onAttachmentsChange, }: TaskBodyProps) { const fileInputRef = useRef(null); - const [isUploading, setIsUploading] = useState(false); const [busyAttachmentId, setBusyAttachmentId] = useState(null); const router = useRouter(); - - const resetState = () => { - setIsUploading(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; + const { execute, isExecuting } = useAction(uploadFile, { + onSuccess: () => { + onAttachmentsChange?.(); + router.refresh(); + toast.success('File uploaded successfully'); + }, + onError: ({ error }) => { + console.error('File upload failed:', error); + toast.error(error.serverError || 'Failed to upload file. Check console for details.'); + }, + onSettled: () => { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + }); const handleFileSelect = useCallback( async (event: React.ChangeEvent) => { const files = event.target.files; if (!files || files.length === 0) return; - setIsUploading(true); - try { - const uploadedAttachments = []; - for (const file of Array.from(files)) { - const MAX_FILE_SIZE_MB = 5; - const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; - if (file.size > MAX_FILE_SIZE_BYTES) { - toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`); - continue; - } - const reader = new FileReader(); - reader.onloadend = async () => { - const base64Data = (reader.result as string)?.split(',')[1]; - if (!base64Data) { - toast.error('Failed to read file data.'); - resetState(); - return; - } - const { success, data, error } = await uploadFile({ - fileName: file.name, - fileType: file.type, - fileData: base64Data, - entityId: taskId, - entityType: AttachmentEntityType.task, - }); + for (const file of Array.from(files)) { + const MAX_FILE_SIZE_MB = 10; + const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + if (file.size > MAX_FILE_SIZE_BYTES) { + toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`); + continue; + } - if (success && data) { - uploadedAttachments.push(data); - } else { - const errorMessage = error || 'Failed to process attachment after upload.'; - toast.error(String(errorMessage)); - } - }; - reader.onerror = () => { - toast.error('Error reading file.'); - resetState(); - }; - reader.readAsDataURL(file); - await new Promise((resolve) => { - const intervalId = setInterval(() => { - if (reader.readyState === FileReader.DONE) { - clearInterval(intervalId); - resolve(undefined); - } - }, 100); + const reader = new FileReader(); + reader.onloadend = async () => { + const base64Data = (reader.result as string)?.split(',')[1]; + if (!base64Data) { + toast.error('Failed to read file data.'); + return; + } + execute({ + fileName: file.name, + fileType: file.type, + fileData: base64Data, + entityId: taskId, + entityType: AttachmentEntityType.task, }); - } - onAttachmentsChange?.(); - router.refresh(); - } finally { - setIsUploading(false); + }; + reader.onerror = () => { + toast.error('Error reading file.'); + }; + reader.readAsDataURL(file); } }, - [taskId, onAttachmentsChange, router], + [execute, taskId, onAttachmentsChange, router], ); const triggerFileInput = () => { @@ -144,8 +129,6 @@ export function TaskBody({ [onAttachmentsChange, router], ); - const isUploadingFile = isUploading; - return (