mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import { supabase } from '@/lib/supabaseClient';
|
|
import type { Json } from '@/integrations/supabase/types';
|
|
import { uploadPendingImages } from './imageUploadHelper';
|
|
import { CompanyFormData, TempCompanyData } from '@/types/company';
|
|
import { handleError } from './errorHandler';
|
|
import { withRetry, isRetryableError } from './retryHelpers';
|
|
import { logger } from './logger';
|
|
import { checkSubmissionRateLimit, recordSubmissionAttempt } from './submissionRateLimiter';
|
|
import { sanitizeErrorMessage } from './errorSanitizer';
|
|
import { reportRateLimitViolation, reportBanEvasionAttempt } from './pipelineAlerts';
|
|
|
|
export type { CompanyFormData, TempCompanyData };
|
|
|
|
/**
|
|
* Rate limiting helper - checks rate limits before allowing submission
|
|
*/
|
|
function checkRateLimitOrThrow(userId: string, action: string): void {
|
|
const rateLimit = checkSubmissionRateLimit(userId);
|
|
|
|
if (!rateLimit.allowed) {
|
|
const sanitizedMessage = sanitizeErrorMessage(rateLimit.reason || 'Rate limit exceeded');
|
|
|
|
logger.warn('[RateLimit] Company submission blocked', {
|
|
userId,
|
|
action,
|
|
reason: rateLimit.reason,
|
|
retryAfter: rateLimit.retryAfter,
|
|
});
|
|
|
|
// Report to system alerts for admin visibility
|
|
reportRateLimitViolation(userId, action, rateLimit.retryAfter || 60).catch(() => {
|
|
// Non-blocking - don't fail submission if alert fails
|
|
});
|
|
|
|
throw new Error(sanitizedMessage);
|
|
}
|
|
|
|
logger.info('[RateLimit] Company submission allowed', {
|
|
userId,
|
|
action,
|
|
remaining: rateLimit.remaining,
|
|
});
|
|
}
|
|
|
|
export async function submitCompanyCreation(
|
|
data: CompanyFormData,
|
|
companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner',
|
|
userId: string
|
|
) {
|
|
// Phase 3: Rate limiting check
|
|
checkRateLimitOrThrow(userId, 'company_creation');
|
|
recordSubmissionAttempt(userId);
|
|
|
|
// Check if user is banned (with quick retry for read operation)
|
|
const profile = await withRetry(
|
|
async () => {
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
return profile;
|
|
},
|
|
{ maxAttempts: 2 }
|
|
);
|
|
|
|
if (profile?.banned) {
|
|
// Report ban evasion attempt
|
|
reportBanEvasionAttempt(userId, 'company_creation').catch(() => {
|
|
// Non-blocking - don't fail if alert fails
|
|
});
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
handleError(error, {
|
|
action: 'Upload company images',
|
|
metadata: { companyType },
|
|
});
|
|
throw new Error('Failed to upload images. Please check your connection and try again.');
|
|
}
|
|
}
|
|
|
|
// Create submission with retry logic
|
|
const retryId = crypto.randomUUID();
|
|
|
|
const result = await withRetry(
|
|
async () => {
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: companyType,
|
|
content: {
|
|
action: 'create'
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual company data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: companyType,
|
|
item_data: {
|
|
name: data.name,
|
|
slug: data.slug,
|
|
description: data.description,
|
|
person_type: data.person_type,
|
|
website_url: data.website_url,
|
|
founded_year: data.founded_year,
|
|
headquarters_location: data.headquarters_location,
|
|
company_type: companyType,
|
|
images: processedImages as unknown as Json
|
|
},
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
},
|
|
{
|
|
maxAttempts: 3,
|
|
onRetry: (attempt, error, delay) => {
|
|
logger.warn('Retrying company submission', { attempt, delay, companyType });
|
|
|
|
// Emit event for UI indicator
|
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
|
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: companyType }
|
|
}));
|
|
},
|
|
shouldRetry: (error) => {
|
|
// Don't retry validation/business logic errors
|
|
if (error instanceof Error) {
|
|
const message = error.message.toLowerCase();
|
|
if (message.includes('required')) return false;
|
|
if (message.includes('banned')) return false;
|
|
if (message.includes('slug')) return false;
|
|
if (message.includes('permission')) return false;
|
|
}
|
|
|
|
return isRetryableError(error);
|
|
}
|
|
}
|
|
).then((data) => {
|
|
// Emit success event
|
|
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
|
detail: { id: retryId }
|
|
}));
|
|
return data;
|
|
}).catch((error) => {
|
|
const errorId = handleError(error, {
|
|
action: `${companyType} submission`,
|
|
metadata: { retriesExhausted: true },
|
|
});
|
|
|
|
// Emit failure event
|
|
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
|
detail: { id: retryId, errorId }
|
|
}));
|
|
|
|
throw error;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function submitCompanyUpdate(
|
|
companyId: string,
|
|
data: CompanyFormData,
|
|
userId: string
|
|
) {
|
|
// Phase 3: Rate limiting check
|
|
checkRateLimitOrThrow(userId, 'company_update');
|
|
recordSubmissionAttempt(userId);
|
|
|
|
// Check if user is banned (with quick retry for read operation)
|
|
const profile = await withRetry(
|
|
async () => {
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('banned')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
return profile;
|
|
},
|
|
{ maxAttempts: 2 }
|
|
);
|
|
|
|
if (profile?.banned) {
|
|
// Report ban evasion attempt
|
|
reportBanEvasionAttempt(userId, 'company_update').catch(() => {
|
|
// Non-blocking - don't fail if alert fails
|
|
});
|
|
throw new Error('Account suspended. Contact support for assistance.');
|
|
}
|
|
|
|
// Fetch existing company data (all fields for original_data)
|
|
const { data: existingCompany, error: fetchError } = await supabase
|
|
.from('companies')
|
|
.select('id, name, slug, description, company_type, person_type, logo_url, card_image_url, banner_image_url, banner_image_id, card_image_id, headquarters_location, website_url, founded_year, founded_date, founded_date_precision')
|
|
.eq('id', companyId)
|
|
.single();
|
|
|
|
if (fetchError) throw fetchError;
|
|
if (!existingCompany) throw new Error('Company not found');
|
|
|
|
// Upload any pending local images first
|
|
let processedImages = data.images;
|
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
|
try {
|
|
const uploadedImages = await uploadPendingImages(data.images.uploaded);
|
|
processedImages = {
|
|
...data.images,
|
|
uploaded: uploadedImages
|
|
};
|
|
} catch (error: unknown) {
|
|
handleError(error, {
|
|
action: 'Upload company images for update',
|
|
metadata: { companyType: existingCompany.company_type, companyId },
|
|
});
|
|
throw new Error('Failed to upload images. Please check your connection and try again.');
|
|
}
|
|
}
|
|
|
|
// Create submission with retry logic
|
|
const retryId = crypto.randomUUID();
|
|
|
|
const result = await withRetry(
|
|
async () => {
|
|
// Create the main submission record
|
|
const { data: submissionData, error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submission_type: existingCompany.company_type,
|
|
content: {
|
|
action: 'edit',
|
|
company_id: companyId
|
|
},
|
|
status: 'pending' as const
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (submissionError) throw submissionError;
|
|
|
|
// Create the submission item with actual company data AND original data
|
|
const { error: itemError } = await supabase
|
|
.from('submission_items')
|
|
.insert({
|
|
submission_id: submissionData.id,
|
|
item_type: existingCompany.company_type,
|
|
item_data: {
|
|
company_id: companyId,
|
|
name: data.name,
|
|
slug: data.slug,
|
|
description: data.description,
|
|
person_type: data.person_type,
|
|
website_url: data.website_url,
|
|
founded_year: data.founded_year,
|
|
headquarters_location: data.headquarters_location,
|
|
images: processedImages as unknown as Json
|
|
},
|
|
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
|
status: 'pending' as const,
|
|
order_index: 0
|
|
});
|
|
|
|
if (itemError) throw itemError;
|
|
|
|
return { submitted: true, submissionId: submissionData.id };
|
|
},
|
|
{
|
|
maxAttempts: 3,
|
|
onRetry: (attempt, error, delay) => {
|
|
logger.warn('Retrying company update', { attempt, delay, companyId });
|
|
|
|
// Emit event for UI indicator
|
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
|
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: `${existingCompany.company_type} update` }
|
|
}));
|
|
},
|
|
shouldRetry: (error) => {
|
|
// Don't retry validation/business logic errors
|
|
if (error instanceof Error) {
|
|
const message = error.message.toLowerCase();
|
|
if (message.includes('required')) return false;
|
|
if (message.includes('banned')) return false;
|
|
if (message.includes('slug')) return false;
|
|
if (message.includes('permission')) return false;
|
|
}
|
|
|
|
return isRetryableError(error);
|
|
}
|
|
}
|
|
).then((data) => {
|
|
// Emit success event
|
|
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
|
detail: { id: retryId }
|
|
}));
|
|
return data;
|
|
}).catch((error) => {
|
|
const errorId = handleError(error, {
|
|
action: `${existingCompany.company_type} update`,
|
|
metadata: { retriesExhausted: true, companyId },
|
|
});
|
|
|
|
// Emit failure event
|
|
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
|
detail: { id: retryId, errorId }
|
|
}));
|
|
|
|
throw error;
|
|
});
|
|
|
|
return result;
|
|
}
|