mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
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:
parent
2633ff0a2a
commit
3babb5ced6
26 changed files with 941 additions and 234 deletions
139
ui/src/app/campaigns/CsvUploadSelector.tsx
Normal file
139
ui/src/app/campaigns/CsvUploadSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue