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">
<Label>Status *</Label>
<Select
onValueChange={(value) => setValue('status', value as any)}
onValueChange={(value) => setValue('status', value)}
defaultValue={initialData?.status || 'operating'}
>
<SelectTrigger>

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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}
/>
);

View File

@@ -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;

View File

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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,