feat: add csv upload functionality for OSS (#29)

feat: add csv upload functionality
chore: remove redundant arq-worker from docker-compose
This commit is contained in:
Abhishek 2025-10-09 17:54:31 +05:30 committed by GitHub
parent 2633ff0a2a
commit 3babb5ced6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 941 additions and 234 deletions

View file

@ -0,0 +1,139 @@
'use client';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import logger from '@/lib/logger';
interface CsvUploadSelectorProps {
accessToken: string;
onFileUploaded: (fileKey: string, fileName: string) => void;
selectedFileName?: string;
}
interface PresignedUploadUrlResponse {
upload_url: string;
file_key: string;
expires_in: number;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export default function CsvUploadSelector({ accessToken, onFileUploaded, selectedFileName }: CsvUploadSelectorProps) {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.name.endsWith('.csv')) {
toast.error('Please select a CSV file');
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
toast.error('File size must be less than 10MB');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// Step 1: Request presigned upload URL
logger.info('Requesting presigned upload URL for:', file.name);
const presignedResponse = await fetch('/api/v1/s3/presigned-upload-url', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_name: file.name,
file_size: file.size,
content_type: 'text/csv',
}),
});
if (!presignedResponse.ok) {
const error = await presignedResponse.json();
throw new Error(error.detail || 'Failed to get upload URL');
}
const presignedData: PresignedUploadUrlResponse = await presignedResponse.json();
logger.info('Received presigned URL, uploading file...');
// Step 2: Upload file directly to S3/MinIO
const uploadResponse = await fetch(presignedData.upload_url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': 'text/csv',
},
});
if (!uploadResponse.ok) {
throw new Error('Failed to upload file to storage');
}
setUploadProgress(100);
logger.info('File uploaded successfully, file_key:', presignedData.file_key);
// Step 3: Notify parent with file_key
onFileUploaded(presignedData.file_key, file.name);
toast.success(`File uploaded: ${file.name}`);
} catch (error) {
logger.error('Error uploading CSV:', error);
toast.error(error instanceof Error ? error.message : 'Failed to upload CSV file');
} finally {
setUploading(false);
setUploadProgress(0);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleButtonClick = () => {
fileInputRef.current?.click();
};
return (
<div className="space-y-2">
<Label>CSV File</Label>
<div className="flex items-center gap-4">
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={handleButtonClick}
disabled={uploading}
>
{uploading ? `Uploading... ${uploadProgress}%` : 'Upload CSV File'}
</Button>
{selectedFileName && !uploading && (
<div className="flex-1 text-sm">
<span className="text-gray-600">Selected: </span>
<span className="text-blue-600">{selectedFileName}</span>
</div>
)}
</div>
<p className="text-sm text-gray-500">
Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB.
</p>
</div>
);
}

View file

@ -8,6 +8,7 @@ import { toast } from 'sonner';
import {
getCampaignApiV1CampaignCampaignIdGet,
getCampaignRunsApiV1CampaignCampaignIdRunsGet,
getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet,
pauseCampaignApiV1CampaignCampaignIdPausePost,
resumeCampaignApiV1CampaignCampaignIdResumePost,
startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen';
@ -125,6 +126,33 @@ export default function CampaignDetailPage() {
}
};
// Handle CSV download
const handleDownloadCsv = async () => {
if (!user || !campaign || campaign.source_type !== 'csv') return;
try {
const accessToken = await getAccessToken();
const response = await getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet({
path: {
campaign_id: campaignId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data?.download_url) {
// Open download URL in new tab
window.open(response.data.download_url, '_blank');
} else {
toast.error('Failed to get download URL');
}
} catch (error) {
console.error('Failed to download CSV:', error);
toast.error('Failed to download CSV file');
}
};
// Handle start campaign
const handleStart = async () => {
if (!user) return;
@ -354,16 +382,27 @@ export default function CampaignDetailPage() {
<dd className="mt-1 capitalize">{campaign.source_type.replace('-', ' ')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source Sheet</dt>
<dt className="text-sm font-medium text-gray-500">
{campaign.source_type === 'csv' ? 'Source File' : 'Source Sheet'}
</dt>
<dd className="mt-1">
<a
href={campaign.source_id}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
>
{campaign.source_id}
</a>
{campaign.source_type === 'csv' ? (
<button
onClick={handleDownloadCsv}
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
>
{campaign.source_id.split('/').pop()}
</button>
) : (
<a
href={campaign.source_id}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
>
{campaign.source_id}
</a>
)}
</dd>
</div>
<div>

View file

@ -20,6 +20,7 @@ import {
} from '@/components/ui/select';
import { useAuth } from '@/lib/auth';
import CsvUploadSelector from '../CsvUploadSelector';
import GoogleSheetSelector from '../GoogleSheetSelector';
export default function NewCampaignPage() {
@ -29,7 +30,9 @@ export default function NewCampaignPage() {
// Form state
const [campaignName, setCampaignName] = useState('');
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>('');
const [selectedSheetUrl, setSelectedSheetUrl] = useState('');
const [sourceType, setSourceType] = useState<'google-sheet' | 'csv'>('csv');
const [sourceId, setSourceId] = useState('');
const [selectedFileName, setSelectedFileName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [userAccessToken, setUserAccessToken] = useState<string>('');
@ -78,7 +81,7 @@ export default function NewCampaignPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!campaignName || !selectedWorkflowId || !selectedSheetUrl) {
if (!campaignName || !selectedWorkflowId || !sourceId) {
toast.error('Please fill in all fields');
return;
}
@ -91,7 +94,8 @@ export default function NewCampaignPage() {
body: {
name: campaignName,
workflow_id: parseInt(selectedWorkflowId),
source_id: selectedSheetUrl,
source_type: sourceType,
source_id: sourceId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
@ -117,7 +121,13 @@ export default function NewCampaignPage() {
// Handle sheet selection
const handleSheetSelected = (sheetUrl: string) => {
setSelectedSheetUrl(sheetUrl);
setSourceId(sheetUrl);
};
// Handle CSV file upload
const handleFileUploaded = (fileKey: string, fileName: string) => {
setSourceId(fileKey);
setSelectedFileName(fileName);
};
return (
@ -191,20 +201,52 @@ export default function NewCampaignPage() {
</SelectContent>
</Select>
<p className="text-sm text-gray-500">
Select the workflow to execute for each row in the spreadsheet
Select the workflow to execute for each row in the data source
</p>
</div>
<GoogleSheetSelector
accessToken={userAccessToken}
onSheetSelected={handleSheetSelected}
selectedSheetUrl={selectedSheetUrl}
/>
<div className="space-y-2">
<Label htmlFor="source-type">Data Source Type</Label>
<Select
value={sourceType}
onValueChange={(value) => {
setSourceType(value as 'google-sheet' | 'csv');
setSourceId('');
setSelectedFileName('');
}}
required
>
<SelectTrigger id="source-type">
<SelectValue placeholder="Select source type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="google-sheet">Google Sheet</SelectItem>
<SelectItem value="csv">CSV File</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-gray-500">
Choose where your contact data is stored
</p>
</div>
{sourceType === 'google-sheet' ? (
<GoogleSheetSelector
accessToken={userAccessToken}
onSheetSelected={handleSheetSelected}
selectedSheetUrl={sourceId}
/>
) : (
<CsvUploadSelector
accessToken={userAccessToken}
onFileUploaded={handleFileUploaded}
selectedFileName={selectedFileName}
/>
)}
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !selectedSheetUrl}
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId}
>
{isSubmitting ? 'Creating...' : 'Create Campaign'}
</Button>

File diff suppressed because one or more lines are too long

View file

@ -72,6 +72,11 @@ export type CampaignResponse = {
completed_at: string | null;
};
export type CampaignSourceDownloadResponse = {
download_url: string;
expires_in: number;
};
export type CampaignsResponse = {
campaigns: Array<CampaignResponse>;
};
@ -91,6 +96,7 @@ export type CreateApiKeyResponse = {
export type CreateCampaignRequest = {
name: string;
workflow_id: number;
source_type: string;
source_id: string;
};
@ -287,6 +293,27 @@ export type LoadTestStatsResponse = {
}>;
};
export type PresignedUploadUrlRequest = {
/**
* CSV filename
*/
file_name: string;
/**
* File size in bytes (max 10MB)
*/
file_size: number;
/**
* File content type
*/
content_type?: string;
};
export type PresignedUploadUrlResponse = {
upload_url: string;
file_key: string;
expires_in: number;
};
export type RtcOfferRequest = {
pc_id: string | null;
sdp: string;
@ -1758,6 +1785,40 @@ export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses = {
export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse = GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses[keyof GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses];
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData = {
body?: never;
headers?: {
authorization?: string | null;
};
path: {
campaign_id: number;
};
query?: never;
url: '/api/v1/campaign/{campaign_id}/source-download-url';
};
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError = GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors[keyof GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors];
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses = {
/**
* Successful Response
*/
200: CampaignSourceDownloadResponse;
};
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse = GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses[keyof GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses];
export type GetIntegrationsApiV1IntegrationGetData = {
body?: never;
headers?: {
@ -2028,6 +2089,38 @@ export type GetFileMetadataApiV1S3FileMetadataGetResponses = {
export type GetFileMetadataApiV1S3FileMetadataGetResponse = GetFileMetadataApiV1S3FileMetadataGetResponses[keyof GetFileMetadataApiV1S3FileMetadataGetResponses];
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData = {
body: PresignedUploadUrlRequest;
headers?: {
authorization?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/s3/presigned-upload-url';
};
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError = GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors[keyof GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors];
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses = {
/**
* Successful Response
*/
200: PresignedUploadUrlResponse;
};
export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse = GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses[keyof GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses];
export type GetServiceKeysApiV1UserServiceKeysGetData = {
body?: never;
headers?: {