Skip to content

Commit 9c812e9

Browse files
authored
Merge pull request #1195 from trycompai/claudio/fix-file-upload
[dev] [claudfuen] claudio/fix-file-upload
2 parents da39550 + f9a4735 commit 9c812e9

3 files changed

Lines changed: 242 additions & 168 deletions

File tree

Lines changed: 64 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use server';
22

33
import { BUCKET_NAME, s3Client } from '@/app/s3';
4+
import { auth } from '@/utils/auth';
45
import { logger } from '@/utils/logger';
56

67
// This log will run as soon as the module is loaded.
@@ -10,8 +11,8 @@ import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
1011
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
1112
import { AttachmentEntityType, AttachmentType, db } from '@db';
1213
import { revalidatePath } from 'next/cache';
14+
import { headers } from 'next/headers';
1315
import { z } from 'zod';
14-
import { authActionClient } from '../safe-action';
1516

1617
function mapFileTypeToAttachmentType(fileType: string): AttachmentType {
1718
const type = fileType.split('/')[0];
@@ -38,19 +39,18 @@ const uploadAttachmentSchema = z.object({
3839
pathToRevalidate: z.string().optional(),
3940
});
4041

41-
export const uploadFile = authActionClient
42-
.inputSchema(uploadAttachmentSchema)
43-
.metadata({
44-
name: 'uploadFile',
45-
track: {
46-
event: 'File Uploaded',
47-
channel: 'server',
48-
},
49-
})
50-
.action(async ({ parsedInput, ctx }) => {
51-
const { fileName, fileType, fileData, entityId, entityType, pathToRevalidate } = parsedInput;
52-
const { session } = ctx;
53-
const organizationId = session.activeOrganizationId;
42+
export const uploadFile = async (input: z.infer<typeof uploadAttachmentSchema>) => {
43+
logger.info(`[uploadFile] Starting upload for ${input.fileName}`);
44+
try {
45+
const { fileName, fileType, fileData, entityId, entityType, pathToRevalidate } =
46+
uploadAttachmentSchema.parse(input);
47+
48+
const session = await auth.api.getSession({ headers: await headers() });
49+
const organizationId = session?.session.activeOrganizationId;
50+
51+
if (!organizationId) {
52+
throw new Error('Not authorized - no organization found');
53+
}
5454

5555
logger.info(`[uploadFile] Starting upload for ${fileName} in org ${organizationId}`);
5656

@@ -66,51 +66,55 @@ export const uploadFile = authActionClient
6666
const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
6767
const key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${sanitizedFileName}`;
6868

69-
try {
70-
logger.info(`[uploadFile] Uploading to S3 with key: ${key}`);
71-
const putCommand = new PutObjectCommand({
72-
Bucket: BUCKET_NAME,
73-
Key: key,
74-
Body: fileBuffer,
75-
ContentType: fileType,
76-
});
77-
await s3Client.send(putCommand);
78-
logger.info(`[uploadFile] S3 upload successful for key: ${key}`);
79-
80-
logger.info(`[uploadFile] Creating attachment record in DB for key: ${key}`);
81-
const attachment = await db.attachment.create({
82-
data: {
83-
name: fileName,
84-
url: key,
85-
type: mapFileTypeToAttachmentType(fileType),
86-
entityId: entityId,
87-
entityType: entityType,
88-
organizationId: organizationId,
89-
},
90-
});
91-
logger.info(`[uploadFile] DB record created with id: ${attachment.id}`);
92-
93-
logger.info(`[uploadFile] Generating signed URL for key: ${key}`);
94-
const getCommand = new GetObjectCommand({
95-
Bucket: BUCKET_NAME,
96-
Key: key,
97-
});
98-
const signedUrl = await getSignedUrl(s3Client, getCommand, {
99-
expiresIn: 900,
100-
});
101-
logger.info(`[uploadFile] Signed URL generated for key: ${key}`);
102-
103-
if (pathToRevalidate) {
104-
revalidatePath(pathToRevalidate);
105-
}
106-
107-
return {
69+
logger.info(`[uploadFile] Uploading to S3 with key: ${key}`);
70+
const putCommand = new PutObjectCommand({
71+
Bucket: BUCKET_NAME,
72+
Key: key,
73+
Body: fileBuffer,
74+
ContentType: fileType,
75+
});
76+
await s3Client.send(putCommand);
77+
logger.info(`[uploadFile] S3 upload successful for key: ${key}`);
78+
79+
logger.info(`[uploadFile] Creating attachment record in DB for key: ${key}`);
80+
const attachment = await db.attachment.create({
81+
data: {
82+
name: fileName,
83+
url: key,
84+
type: mapFileTypeToAttachmentType(fileType),
85+
entityId: entityId,
86+
entityType: entityType,
87+
organizationId: organizationId,
88+
},
89+
});
90+
logger.info(`[uploadFile] DB record created with id: ${attachment.id}`);
91+
92+
logger.info(`[uploadFile] Generating signed URL for key: ${key}`);
93+
const getCommand = new GetObjectCommand({
94+
Bucket: BUCKET_NAME,
95+
Key: key,
96+
});
97+
const signedUrl = await getSignedUrl(s3Client, getCommand, {
98+
expiresIn: 900,
99+
});
100+
logger.info(`[uploadFile] Signed URL generated for key: ${key}`);
101+
102+
if (pathToRevalidate) {
103+
revalidatePath(pathToRevalidate);
104+
}
105+
106+
return {
107+
success: true,
108+
data: {
108109
...attachment,
109110
signedUrl,
110-
};
111-
} catch (error) {
112-
logger.error(`[uploadFile] Error during upload process for key ${key}:`, error);
113-
// Re-throw the error to be handled by the safe action client
114-
throw error;
115-
}
116-
});
111+
},
112+
} as const;
113+
} catch (error) {
114+
logger.error(`[uploadFile] Error during upload process:`, error);
115+
return {
116+
success: false,
117+
error: error instanceof Error ? error.message : 'An unknown error occurred.',
118+
} as const;
119+
}
120+
};

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx

Lines changed: 119 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { Textarea } from '@comp/ui/textarea';
77
import type { Attachment } from '@db';
88
import { AttachmentEntityType } from '@db';
99
import { Loader2, Paperclip, Plus } from 'lucide-react';
10-
import { useAction } from 'next-safe-action/hooks';
1110
import { useRouter } from 'next/navigation';
1211
import type React from 'react';
1312
import { useCallback, useRef, useState } from 'react';
@@ -38,60 +37,127 @@ export function TaskBody({
3837
onAttachmentsChange,
3938
}: TaskBodyProps) {
4039
const fileInputRef = useRef<HTMLInputElement>(null);
40+
const [isUploading, setIsUploading] = useState(false);
4141
const [busyAttachmentId, setBusyAttachmentId] = useState<string | null>(null);
4242
const router = useRouter();
43-
const { execute, isExecuting } = useAction(uploadFile, {
44-
onSuccess: () => {
45-
onAttachmentsChange?.();
46-
router.refresh();
47-
toast.success('File uploaded successfully');
48-
},
49-
onError: ({ error }) => {
50-
console.error('File upload failed:', error);
51-
toast.error(error.serverError || 'Failed to upload file. Check console for details.');
52-
},
53-
onSettled: () => {
54-
if (fileInputRef.current) {
55-
fileInputRef.current.value = '';
56-
}
57-
},
58-
});
43+
44+
const resetState = () => {
45+
setIsUploading(false);
46+
if (fileInputRef.current) {
47+
fileInputRef.current.value = '';
48+
}
49+
};
5950

6051
const handleFileSelect = useCallback(
6152
async (event: React.ChangeEvent<HTMLInputElement>) => {
6253
const files = event.target.files;
6354
if (!files || files.length === 0) return;
55+
setIsUploading(true);
56+
try {
57+
for (const file of Array.from(files)) {
58+
const MAX_FILE_SIZE_MB = 10;
59+
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
60+
if (file.size > MAX_FILE_SIZE_BYTES) {
61+
toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`);
62+
continue;
63+
}
6464

65-
for (const file of Array.from(files)) {
66-
const MAX_FILE_SIZE_MB = 10;
67-
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
68-
if (file.size > MAX_FILE_SIZE_BYTES) {
69-
toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`);
70-
continue;
65+
const reader = new FileReader();
66+
reader.onloadend = async () => {
67+
const base64Data = (reader.result as string)?.split(',')[1];
68+
if (!base64Data) {
69+
toast.error('Failed to read file data.');
70+
resetState();
71+
return;
72+
}
73+
74+
const result = await uploadFile({
75+
fileName: file.name,
76+
fileType: file.type,
77+
fileData: base64Data,
78+
entityId: taskId,
79+
entityType: AttachmentEntityType.task,
80+
});
81+
82+
if (result.success) {
83+
toast.success('File uploaded successfully.');
84+
onAttachmentsChange?.();
85+
router.refresh();
86+
} else {
87+
console.error('File upload failed:', result.error);
88+
toast.error(result.error || 'Failed to upload file. Check console for details.');
89+
}
90+
};
91+
reader.onerror = () => {
92+
toast.error('Error reading file.');
93+
resetState();
94+
};
95+
reader.readAsDataURL(file);
7196
}
97+
} finally {
98+
// This finally block might run before all file readers are done.
99+
// It's better to manage the loading state inside the onloadend.
100+
}
101+
},
102+
[taskId, onAttachmentsChange, router],
103+
);
104+
105+
// A better way to handle multiple file uploads
106+
const handleFileSelectMultiple = useCallback(
107+
async (event: React.ChangeEvent<HTMLInputElement>) => {
108+
const files = event.target.files;
109+
if (!files || files.length === 0) return;
110+
setIsUploading(true);
72111

73-
const reader = new FileReader();
74-
reader.onloadend = async () => {
75-
const base64Data = (reader.result as string)?.split(',')[1];
76-
if (!base64Data) {
77-
toast.error('Failed to read file data.');
78-
return;
112+
const uploadPromises = Array.from(files).map((file) => {
113+
return new Promise((resolve, reject) => {
114+
const MAX_FILE_SIZE_MB = 10;
115+
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
116+
if (file.size > MAX_FILE_SIZE_BYTES) {
117+
toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`);
118+
return resolve(null); // Resolve to skip this file
79119
}
80-
execute({
81-
fileName: file.name,
82-
fileType: file.type,
83-
fileData: base64Data,
84-
entityId: taskId,
85-
entityType: AttachmentEntityType.task,
86-
});
87-
};
88-
reader.onerror = () => {
89-
toast.error('Error reading file.');
90-
};
91-
reader.readAsDataURL(file);
92-
}
120+
const reader = new FileReader();
121+
reader.onloadend = async () => {
122+
try {
123+
const base64Data = (reader.result as string)?.split(',')[1];
124+
if (!base64Data) {
125+
throw new Error('Failed to read file data.');
126+
}
127+
const result = await uploadFile({
128+
fileName: file.name,
129+
fileType: file.type,
130+
fileData: base64Data,
131+
entityId: taskId,
132+
entityType: AttachmentEntityType.task,
133+
});
134+
if (result.success) {
135+
toast.success(`File "${file.name}" uploaded successfully.`);
136+
resolve(result);
137+
} else {
138+
throw new Error(result.error);
139+
}
140+
} catch (error) {
141+
console.error(`Failed to upload ${file.name}:`, error);
142+
toast.error(`Failed to upload ${file.name}.`);
143+
resolve(null); // Resolve even if there's an error to not break Promise.all
144+
}
145+
};
146+
reader.onerror = () => {
147+
toast.error(`Error reading file "${file.name}".`);
148+
resolve(null);
149+
};
150+
reader.readAsDataURL(file);
151+
});
152+
});
153+
154+
await Promise.all(uploadPromises);
155+
156+
onAttachmentsChange?.();
157+
router.refresh();
158+
resetState();
93159
},
94-
[execute, taskId, onAttachmentsChange, router],
160+
[taskId, onAttachmentsChange, router],
95161
);
96162

97163
const triggerFileInput = () => {
@@ -136,21 +202,21 @@ export function TaskBody({
136202
onChange={onTitleChange}
137203
className="h-auto shrink-0 border-none bg-transparent p-0 md:text-lg font-semibold tracking-tight shadow-none focus-visible:ring-0"
138204
placeholder="Task Title"
139-
disabled={disabled || isExecuting || !!busyAttachmentId}
205+
disabled={disabled || isUploading || !!busyAttachmentId}
140206
/>
141207
<Textarea
142208
value={description}
143209
onChange={onDescriptionChange}
144210
placeholder="Add description..."
145211
className="text-muted-foreground text-md min-h-[80px] resize-none border-none p-0 shadow-none focus-visible:ring-0"
146-
disabled={disabled || isExecuting || !!busyAttachmentId}
212+
disabled={disabled || isUploading || !!busyAttachmentId}
147213
/>
148214
<input
149215
type="file"
150216
ref={fileInputRef}
151-
onChange={handleFileSelect}
217+
onChange={handleFileSelectMultiple}
152218
className="hidden"
153-
disabled={isExecuting || !!busyAttachmentId}
219+
disabled={isUploading || !!busyAttachmentId}
154220
multiple
155221
/>
156222
<div className="space-y-3">
@@ -161,11 +227,11 @@ export function TaskBody({
161227
variant="ghost"
162228
size="icon"
163229
onClick={triggerFileInput}
164-
disabled={isExecuting || !!busyAttachmentId}
230+
disabled={isUploading || !!busyAttachmentId}
165231
className="text-muted-foreground hover:text-foreground flex h-7 w-7 items-center justify-center"
166232
aria-label="Add attachment"
167233
>
168-
{isExecuting ? (
234+
{isUploading ? (
169235
<Loader2 className="h-4 w-4 animate-spin" />
170236
) : (
171237
<Paperclip className="h-4 w-4" />
@@ -185,13 +251,13 @@ export function TaskBody({
185251
onClickFilename={handleDownloadClick}
186252
onDelete={handleDeleteAttachment}
187253
isBusy={isBusy}
188-
isParentBusy={isExecuting}
254+
isParentBusy={isUploading}
189255
/>
190256
);
191257
})}
192258
</div>
193259
) : (
194-
!isExecuting && (
260+
!isUploading && (
195261
<p className="text-muted-foreground pt-1 text-sm italic">
196262
No attachments yet. Click the <Paperclip className="inline h-4 w-4" /> icon above to
197263
add one.
@@ -203,11 +269,11 @@ export function TaskBody({
203269
<Button
204270
variant="outline"
205271
onClick={triggerFileInput}
206-
disabled={isExecuting || !!busyAttachmentId}
272+
disabled={isUploading || !!busyAttachmentId}
207273
className="mt-2 w-full justify-center gap-2"
208274
aria-label="Add attachment"
209275
>
210-
{isExecuting ? (
276+
{isUploading ? (
211277
<Loader2 className="h-4 w-4 animate-spin" />
212278
) : (
213279
<Plus className="h-4 w-4" />

0 commit comments

Comments
 (0)