Fix: Type safety and unit validation

This commit is contained in:
gpt-engineer-app[bot]
2025-10-21 15:05:50 +00:00
parent bcba0a4f0c
commit 65a6ed1acb
10 changed files with 146 additions and 32 deletions

View File

@@ -293,7 +293,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
<div className="space-y-2"> <div className="space-y-2">
<Label>Status *</Label> <Label>Status *</Label>
<Select <Select
onValueChange={(value) => setValue('status', value as any)} onValueChange={(value) => setValue('status', value)}
defaultValue={initialData?.status || 'operating'} defaultValue={initialData?.status || 'operating'}
> >
<SelectTrigger> <SelectTrigger>

View File

@@ -29,8 +29,8 @@ import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { ManufacturerForm } from './ManufacturerForm'; import { ManufacturerForm } from './ManufacturerForm';
import { RideModelForm } from './RideModelForm'; import { RideModelForm } from './RideModelForm';
import { TechnicalSpecsEditor } from './editors/TechnicalSpecsEditor'; import { TechnicalSpecsEditor, validateTechnicalSpecs } from './editors/TechnicalSpecsEditor';
import { CoasterStatsEditor } from './editors/CoasterStatsEditor'; import { CoasterStatsEditor, validateCoasterStats } from './editors/CoasterStatsEditor';
import { FormerNamesEditor } from './editors/FormerNamesEditor'; import { FormerNamesEditor } from './editors/FormerNamesEditor';
import { import {
convertValueToMetric, convertValueToMetric,
@@ -222,6 +222,31 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
const handleFormSubmit = async (data: RideFormData) => { const handleFormSubmit = async (data: RideFormData) => {
try { 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 // Convert form values back to metric for storage
const metricData = { const metricData = {
@@ -349,7 +374,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
<div className="space-y-2"> <div className="space-y-2">
<Label>Status *</Label> <Label>Status *</Label>
<Select <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'} defaultValue={initialData?.status || 'operating'}
> >
<SelectTrigger> <SelectTrigger>

View File

@@ -1,4 +1,5 @@
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -15,6 +16,7 @@ import {
getDisplayUnit getDisplayUnit
} from "@/lib/units"; } from "@/lib/units";
import { validateMetricUnit } from "@/lib/unitValidation"; import { validateMetricUnit } from "@/lib/unitValidation";
import { getErrorMessage } from "@/lib/errorHandler";
interface CoasterStat { interface CoasterStat {
stat_name: string; stat_name: string;
@@ -49,6 +51,7 @@ export function CoasterStatsEditor({
categories = DEFAULT_CATEGORIES categories = DEFAULT_CATEGORIES
}: CoasterStatsEditorProps) { }: CoasterStatsEditorProps) {
const { preferences } = useUnitPreferences(); const { preferences } = useUnitPreferences();
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
const addStat = () => { const addStat = () => {
onChange([ onChange([
@@ -91,8 +94,17 @@ export function CoasterStatsEditor({
try { try {
validateMetricUnit(value, 'Unit'); validateMetricUnit(value, 'Unit');
newStats[index] = { ...newStats[index], [field]: value }; newStats[index] = { ...newStats[index], [field]: value };
} catch (error) { // Clear error for this index
toast.error(`Invalid unit: ${value}. Please use metric units only (km/h, m, cm, kg, G, etc.)`); 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; return;
} }
} else { } else {
@@ -202,7 +214,14 @@ export function CoasterStatsEditor({
value={stat.unit || ''} value={stat.unit || ''}
onChange={(e) => updateStat(index, 'unit', e.target.value)} onChange={(e) => updateStat(index, 'unit', e.target.value)}
placeholder="km/h, m, G..." 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>
</div> </div>
@@ -253,3 +272,28 @@ export function CoasterStatsEditor({
</div> </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 };
}

View File

@@ -1,4 +1,5 @@
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -13,7 +14,8 @@ import {
getMetricUnit, getMetricUnit,
getDisplayUnit getDisplayUnit
} from "@/lib/units"; } from "@/lib/units";
import { validateMetricUnit } from "@/lib/unitValidation"; import { validateMetricUnit, METRIC_UNITS } from "@/lib/unitValidation";
import { getErrorMessage } from "@/lib/errorHandler";
interface TechnicalSpec { interface TechnicalSpec {
spec_name: string; spec_name: string;
@@ -32,7 +34,6 @@ interface TechnicalSpecsEditorProps {
} }
const DEFAULT_CATEGORIES = ['Performance', 'Safety', 'Design', 'Capacity', 'Technical', 'Other']; 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({ export function TechnicalSpecsEditor({
specs, specs,
@@ -41,6 +42,7 @@ export function TechnicalSpecsEditor({
commonSpecs = [] commonSpecs = []
}: TechnicalSpecsEditorProps) { }: TechnicalSpecsEditorProps) {
const { preferences } = useUnitPreferences(); const { preferences } = useUnitPreferences();
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
const addSpec = () => { const addSpec = () => {
onChange([ onChange([
@@ -70,8 +72,17 @@ export function TechnicalSpecsEditor({
try { try {
validateMetricUnit(value, 'Unit'); validateMetricUnit(value, 'Unit');
newSpecs[index] = { ...newSpecs[index], [field]: value }; newSpecs[index] = { ...newSpecs[index], [field]: value };
} catch (error) { // Clear error for this index
toast.error(`Invalid unit: ${value}. Please use metric units only (m, km/h, cm, kg, celsius, etc.)`); 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; return;
} }
} else { } else {
@@ -220,10 +231,17 @@ export function TechnicalSpecsEditor({
onChange={(e) => updateSpec(index, 'unit', e.target.value)} onChange={(e) => updateSpec(index, 'unit', e.target.value)}
placeholder="Unit" placeholder="Unit"
list={`units-${index}`} list={`units-${index}`}
className={unitErrors[index] ? 'border-destructive' : ''}
/> />
<datalist id={`units-${index}`}> <datalist id={`units-${index}`}>
{COMMON_UNITS.map(u => <option key={u} value={u} />)} {METRIC_UNITS.map(u => <option key={u} value={u} />)}
</datalist> </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> </div>
<Button <Button
type="button" type="button"
@@ -242,3 +260,28 @@ export function TechnicalSpecsEditor({
</div> </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 };
}

View File

@@ -55,17 +55,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
...classNames, ...classNames,
}} }}
components={{ components={{
// DOCUMENTED EXCEPTION: Radix UI Calendar types require complex casting IconLeft: () => <ChevronLeft className="h-4 w-4" />,
// The react-day-picker component's internal types don't match the external API IconRight: () => <ChevronRight className="h-4 w-4" />,
// 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}
{...props} {...props}
/> />
); );

View File

@@ -356,7 +356,12 @@ export async function performModerationAction(
// Use type-safe table queries based on item type // Use type-safe table queries based on item type
if (item.type === 'review') { 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, moderation_status: action,
moderated_at: new Date().toISOString(), moderated_at: new Date().toISOString(),
moderated_by: moderatorId, moderated_by: moderatorId,
@@ -364,13 +369,18 @@ export async function performModerationAction(
}; };
const result = await createTableQuery('reviews') const result = await createTableQuery('reviews')
.update(reviewUpdate as any) // Type assertion needed for dynamic update .update(reviewUpdate)
.eq('id', item.id) .eq('id', item.id)
.select(); .select();
error = result.error; error = result.error;
data = result.data; data = result.data;
} else { } else {
const submissionUpdate = { const submissionUpdate: {
status: 'approved' | 'rejected' | 'pending';
reviewed_at: string;
reviewer_id: string;
reviewer_notes?: string;
} = {
status: action, status: action,
reviewed_at: new Date().toISOString(), reviewed_at: new Date().toISOString(),
reviewer_id: moderatorId, reviewer_id: moderatorId,
@@ -378,7 +388,7 @@ export async function performModerationAction(
}; };
const result = await createTableQuery('content_submissions') const result = await createTableQuery('content_submissions')
.update(submissionUpdate as any) // Type assertion needed for dynamic update .update(submissionUpdate)
.eq('id', item.id) .eq('id', item.id)
.select(); .select();
error = result.error; error = result.error;

View File

@@ -249,7 +249,7 @@ class NotificationService {
previous: previousPrefs || null, previous: previousPrefs || null,
updated: validated, updated: validated,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
} as any }
}]); }]);
logger.info('Notification preferences updated', { logger.info('Notification preferences updated', {

View File

@@ -107,8 +107,8 @@ interface RequestMetadata {
} }
async function logRequestMetadata(metadata: RequestMetadata): Promise<void> { async function logRequestMetadata(metadata: RequestMetadata): Promise<void> {
// Type assertion needed until Supabase types regenerate after migration // Safe cast - RPC function exists in database
const { error } = await supabase.rpc('log_request_metadata' as any, { const { error } = await supabase.rpc('log_request_metadata' as 'log_request_metadata', {
p_request_id: metadata.requestId, p_request_id: metadata.requestId,
p_user_id: metadata.userId || null, p_user_id: metadata.userId || null,
p_endpoint: metadata.endpoint, p_endpoint: metadata.endpoint,

View File

@@ -556,8 +556,8 @@ export function extractChangedFields<T extends Record<string, any>>(
// ═══ SPECIAL HANDLING: LOCATION OBJECTS ═══ // ═══ SPECIAL HANDLING: LOCATION OBJECTS ═══
// Location can be an object (from form) vs location_id (from DB) // Location can be an object (from form) vs location_id (from DB)
if (key === 'location' && newValue && typeof newValue === 'object') { if (key === 'location' && newValue && typeof newValue === 'object') {
const oldLoc = originalData.location as any; const oldLoc = originalData.location;
if (!oldLoc || !isEqual(oldLoc, newValue)) { if (!oldLoc || typeof oldLoc !== 'object' || !isEqual(oldLoc, newValue)) {
changes[key as keyof T] = newValue; changes[key as keyof T] = newValue;
} }
return; return;

View File

@@ -738,7 +738,7 @@ export default function RideDetail() {
description: ride.description, description: ride.description,
category: ride.category, category: ride.category,
ride_sub_type: ride.ride_sub_type, 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, opening_date: ride.opening_date,
closing_date: ride.closing_date, closing_date: ride.closing_date,
height_requirement: ride.height_requirement, height_requirement: ride.height_requirement,