mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 15:11:12 -05:00
Fix: Type safety and unit validation
This commit is contained in:
@@ -293,7 +293,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
<div className="space-y-2">
|
||||
<Label>Status *</Label>
|
||||
<Select
|
||||
onValueChange={(value) => setValue('status', value as any)}
|
||||
onValueChange={(value) => setValue('status', value)}
|
||||
defaultValue={initialData?.status || 'operating'}
|
||||
>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -29,8 +29,8 @@ import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { ManufacturerForm } from './ManufacturerForm';
|
||||
import { RideModelForm } from './RideModelForm';
|
||||
import { TechnicalSpecsEditor } from './editors/TechnicalSpecsEditor';
|
||||
import { CoasterStatsEditor } from './editors/CoasterStatsEditor';
|
||||
import { TechnicalSpecsEditor, validateTechnicalSpecs } from './editors/TechnicalSpecsEditor';
|
||||
import { CoasterStatsEditor, validateCoasterStats } from './editors/CoasterStatsEditor';
|
||||
import { FormerNamesEditor } from './editors/FormerNamesEditor';
|
||||
import {
|
||||
convertValueToMetric,
|
||||
@@ -222,6 +222,31 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
|
||||
const handleFormSubmit = async (data: RideFormData) => {
|
||||
try {
|
||||
// Validate coaster stats
|
||||
if (coasterStats && coasterStats.length > 0) {
|
||||
const statsValidation = validateCoasterStats(coasterStats);
|
||||
if (!statsValidation.valid) {
|
||||
toast({
|
||||
title: 'Invalid coaster statistics',
|
||||
description: statsValidation.errors.join(', '),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate technical specs
|
||||
if (technicalSpecs && technicalSpecs.length > 0) {
|
||||
const specsValidation = validateTechnicalSpecs(technicalSpecs);
|
||||
if (!specsValidation.valid) {
|
||||
toast({
|
||||
title: 'Invalid technical specifications',
|
||||
description: specsValidation.errors.join(', '),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert form values back to metric for storage
|
||||
const metricData = {
|
||||
@@ -349,7 +374,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
<div className="space-y-2">
|
||||
<Label>Status *</Label>
|
||||
<Select
|
||||
onValueChange={(value) => setValue('status', value as any)}
|
||||
onValueChange={(value) => setValue('status', value as "operating" | "closed_permanently" | "closed_temporarily" | "under_construction" | "relocated" | "stored" | "demolished")}
|
||||
defaultValue={initialData?.status || 'operating'}
|
||||
>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
getDisplayUnit
|
||||
} from "@/lib/units";
|
||||
import { validateMetricUnit } from "@/lib/unitValidation";
|
||||
import { getErrorMessage } from "@/lib/errorHandler";
|
||||
|
||||
interface CoasterStat {
|
||||
stat_name: string;
|
||||
@@ -49,6 +51,7 @@ export function CoasterStatsEditor({
|
||||
categories = DEFAULT_CATEGORIES
|
||||
}: CoasterStatsEditorProps) {
|
||||
const { preferences } = useUnitPreferences();
|
||||
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const addStat = () => {
|
||||
onChange([
|
||||
@@ -91,8 +94,17 @@ export function CoasterStatsEditor({
|
||||
try {
|
||||
validateMetricUnit(value, 'Unit');
|
||||
newStats[index] = { ...newStats[index], [field]: value };
|
||||
} catch (error) {
|
||||
toast.error(`Invalid unit: ${value}. Please use metric units only (km/h, m, cm, kg, G, etc.)`);
|
||||
// Clear error for this index
|
||||
setUnitErrors(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[index];
|
||||
return updated;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error);
|
||||
toast.error(message);
|
||||
// Store error for visual feedback
|
||||
setUnitErrors(prev => ({ ...prev, [index]: message }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -202,7 +214,14 @@ export function CoasterStatsEditor({
|
||||
value={stat.unit || ''}
|
||||
onChange={(e) => updateStat(index, 'unit', e.target.value)}
|
||||
placeholder="km/h, m, G..."
|
||||
className={unitErrors[index] ? 'border-destructive' : ''}
|
||||
/>
|
||||
{unitErrors[index] && (
|
||||
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
⚠️ Use metric units only: km/h, m, cm, kg, G, celsius
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,3 +272,28 @@ export function CoasterStatsEditor({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates coaster stats before submission
|
||||
*/
|
||||
export function validateCoasterStats(stats: CoasterStat[]): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
stats.forEach((stat, index) => {
|
||||
if (!stat.stat_name?.trim()) {
|
||||
errors.push(`Stat ${index + 1}: Name is required`);
|
||||
}
|
||||
if (stat.stat_value === null || stat.stat_value === undefined) {
|
||||
errors.push(`Stat ${index + 1} (${stat.stat_name}): Value is required`);
|
||||
}
|
||||
if (stat.unit) {
|
||||
try {
|
||||
validateMetricUnit(stat.unit, `Stat ${index + 1} (${stat.stat_name})`);
|
||||
} catch (error: unknown) {
|
||||
errors.push(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
getMetricUnit,
|
||||
getDisplayUnit
|
||||
} from "@/lib/units";
|
||||
import { validateMetricUnit } from "@/lib/unitValidation";
|
||||
import { validateMetricUnit, METRIC_UNITS } from "@/lib/unitValidation";
|
||||
import { getErrorMessage } from "@/lib/errorHandler";
|
||||
|
||||
interface TechnicalSpec {
|
||||
spec_name: string;
|
||||
@@ -32,7 +34,6 @@ interface TechnicalSpecsEditorProps {
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORIES = ['Performance', 'Safety', 'Design', 'Capacity', 'Technical', 'Other'];
|
||||
const COMMON_UNITS = ['m', 'km/h', 'mph', 'ft', 'seconds', 'minutes', 'kg', 'lbs', 'passengers', '%'];
|
||||
|
||||
export function TechnicalSpecsEditor({
|
||||
specs,
|
||||
@@ -41,6 +42,7 @@ export function TechnicalSpecsEditor({
|
||||
commonSpecs = []
|
||||
}: TechnicalSpecsEditorProps) {
|
||||
const { preferences } = useUnitPreferences();
|
||||
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const addSpec = () => {
|
||||
onChange([
|
||||
@@ -70,8 +72,17 @@ export function TechnicalSpecsEditor({
|
||||
try {
|
||||
validateMetricUnit(value, 'Unit');
|
||||
newSpecs[index] = { ...newSpecs[index], [field]: value };
|
||||
} catch (error) {
|
||||
toast.error(`Invalid unit: ${value}. Please use metric units only (m, km/h, cm, kg, celsius, etc.)`);
|
||||
// Clear error for this index
|
||||
setUnitErrors(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[index];
|
||||
return updated;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error);
|
||||
toast.error(message);
|
||||
// Store error for visual feedback
|
||||
setUnitErrors(prev => ({ ...prev, [index]: message }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -220,10 +231,17 @@ export function TechnicalSpecsEditor({
|
||||
onChange={(e) => updateSpec(index, 'unit', e.target.value)}
|
||||
placeholder="Unit"
|
||||
list={`units-${index}`}
|
||||
className={unitErrors[index] ? 'border-destructive' : ''}
|
||||
/>
|
||||
<datalist id={`units-${index}`}>
|
||||
{COMMON_UNITS.map(u => <option key={u} value={u} />)}
|
||||
{METRIC_UNITS.map(u => <option key={u} value={u} />)}
|
||||
</datalist>
|
||||
{unitErrors[index] && (
|
||||
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
⚠️ Metric units only
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -242,3 +260,28 @@ export function TechnicalSpecsEditor({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates technical specs before submission
|
||||
*/
|
||||
export function validateTechnicalSpecs(specs: TechnicalSpec[]): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
specs.forEach((spec, index) => {
|
||||
if (!spec.spec_name?.trim()) {
|
||||
errors.push(`Spec ${index + 1}: Name is required`);
|
||||
}
|
||||
if (!spec.spec_value?.trim()) {
|
||||
errors.push(`Spec ${index + 1} (${spec.spec_name}): Value is required`);
|
||||
}
|
||||
if (spec.unit) {
|
||||
try {
|
||||
validateMetricUnit(spec.unit, `Spec ${index + 1} (${spec.spec_name})`);
|
||||
} catch (error: unknown) {
|
||||
errors.push(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -55,17 +55,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
// DOCUMENTED EXCEPTION: Radix UI Calendar types require complex casting
|
||||
// The react-day-picker component's internal types don't match the external API
|
||||
// Safe because React validates component props at runtime
|
||||
// See: https://github.com/gpbl/react-day-picker/issues
|
||||
Chevron: ({ orientation, ...props }: any) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeft className="h-4 w-4" />;
|
||||
}
|
||||
return <ChevronRight className="h-4 w-4" />;
|
||||
},
|
||||
} as any}
|
||||
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: () => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -356,7 +356,12 @@ export async function performModerationAction(
|
||||
|
||||
// Use type-safe table queries based on item type
|
||||
if (item.type === 'review') {
|
||||
const reviewUpdate = {
|
||||
const reviewUpdate: {
|
||||
moderation_status: 'approved' | 'rejected' | 'pending';
|
||||
moderated_at: string;
|
||||
moderated_by: string;
|
||||
reviewer_notes?: string;
|
||||
} = {
|
||||
moderation_status: action,
|
||||
moderated_at: new Date().toISOString(),
|
||||
moderated_by: moderatorId,
|
||||
@@ -364,13 +369,18 @@ export async function performModerationAction(
|
||||
};
|
||||
|
||||
const result = await createTableQuery('reviews')
|
||||
.update(reviewUpdate as any) // Type assertion needed for dynamic update
|
||||
.update(reviewUpdate)
|
||||
.eq('id', item.id)
|
||||
.select();
|
||||
error = result.error;
|
||||
data = result.data;
|
||||
} else {
|
||||
const submissionUpdate = {
|
||||
const submissionUpdate: {
|
||||
status: 'approved' | 'rejected' | 'pending';
|
||||
reviewed_at: string;
|
||||
reviewer_id: string;
|
||||
reviewer_notes?: string;
|
||||
} = {
|
||||
status: action,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
reviewer_id: moderatorId,
|
||||
@@ -378,7 +388,7 @@ export async function performModerationAction(
|
||||
};
|
||||
|
||||
const result = await createTableQuery('content_submissions')
|
||||
.update(submissionUpdate as any) // Type assertion needed for dynamic update
|
||||
.update(submissionUpdate)
|
||||
.eq('id', item.id)
|
||||
.select();
|
||||
error = result.error;
|
||||
|
||||
@@ -249,7 +249,7 @@ class NotificationService {
|
||||
previous: previousPrefs || null,
|
||||
updated: validated,
|
||||
timestamp: new Date().toISOString()
|
||||
} as any
|
||||
}
|
||||
}]);
|
||||
|
||||
logger.info('Notification preferences updated', {
|
||||
|
||||
@@ -107,8 +107,8 @@ interface RequestMetadata {
|
||||
}
|
||||
|
||||
async function logRequestMetadata(metadata: RequestMetadata): Promise<void> {
|
||||
// Type assertion needed until Supabase types regenerate after migration
|
||||
const { error } = await supabase.rpc('log_request_metadata' as any, {
|
||||
// Safe cast - RPC function exists in database
|
||||
const { error } = await supabase.rpc('log_request_metadata' as 'log_request_metadata', {
|
||||
p_request_id: metadata.requestId,
|
||||
p_user_id: metadata.userId || null,
|
||||
p_endpoint: metadata.endpoint,
|
||||
|
||||
@@ -556,8 +556,8 @@ export function extractChangedFields<T extends Record<string, any>>(
|
||||
// ═══ SPECIAL HANDLING: LOCATION OBJECTS ═══
|
||||
// Location can be an object (from form) vs location_id (from DB)
|
||||
if (key === 'location' && newValue && typeof newValue === 'object') {
|
||||
const oldLoc = originalData.location as any;
|
||||
if (!oldLoc || !isEqual(oldLoc, newValue)) {
|
||||
const oldLoc = originalData.location;
|
||||
if (!oldLoc || typeof oldLoc !== 'object' || !isEqual(oldLoc, newValue)) {
|
||||
changes[key as keyof T] = newValue;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -738,7 +738,7 @@ export default function RideDetail() {
|
||||
description: ride.description,
|
||||
category: ride.category,
|
||||
ride_sub_type: ride.ride_sub_type,
|
||||
status: ride.status as any, // Type assertion for DB status
|
||||
status: ride.status as "operating" | "closed_permanently" | "closed_temporarily" | "under_construction" | "relocated" | "stored" | "demolished",
|
||||
opening_date: ride.opening_date,
|
||||
closing_date: ride.closing_date,
|
||||
height_requirement: ride.height_requirement,
|
||||
|
||||
Reference in New Issue
Block a user