mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 18: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">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user