Przeglądaj źródła

mf race condiction 🐛🐛🐛🐛🐛

Matthew Trejo 2 miesięcy temu
rodzic
commit
a06f8c81e8

+ 4 - 8
src/app/account/page.tsx

@@ -11,6 +11,7 @@ import ProfileImageSection from "@/components/account/ProfileImageSection"
 import PersonalInfoSection from "@/components/account/PersonalInfoSection"
 import PasswordChangeSection from "@/components/account/PasswordChangeSection"
 import MedicalInfoSection from "@/components/account/MedicalInfoSection"
+import AccountHeader from "@/components/account/AccountHeader"
 
 export default function AccountPage() {
   const { data: session, status } = useSession()
@@ -62,14 +63,9 @@ export default function AccountPage() {
     <AuthenticatedLayout>
       <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
         {/* Header */}
-        <div className="mb-8">
-          <h1 className="text-3xl font-bold text-gray-900 mb-2">
-            Mi Cuenta
-          </h1>
-          <p className="text-gray-600">
-            Gestiona tu información personal y configuración de la cuenta
-          </p>
-        </div>
+        <AccountHeader 
+          userName={session.user.name ? `${session.user.name} ${session.user.lastname || ''}`.trim() : undefined}
+        />
 
         <form onSubmit={handleSubmit}>
           <div className="grid lg:grid-cols-3 gap-6">

+ 0 - 13
src/components/RecordsList.tsx

@@ -65,19 +65,6 @@ export default function RecordsList() {
     return <RecordsUnauthorizedState />;
   }
 
-  if (loading) {
-    return (
-      <RecordsLoadingState
-        title={
-          session?.user.role === "DOCTOR"
-            ? "Todos los Reportes Médicos"
-            : "Mis Reportes Médicos"
-        }
-        subtitle="Cargando reportes..."
-      />
-    );
-  }
-
   return (
     <div className="max-w-6xl mx-auto p-6">
       <RecordsHeader

+ 31 - 0
src/components/account/AccountHeader.tsx

@@ -0,0 +1,31 @@
+"use client"
+
+import { User } from "lucide-react"
+
+interface AccountHeaderProps {
+  userName?: string
+}
+
+export default function AccountHeader({ userName }: AccountHeaderProps) {
+  return (
+    <div className="mb-6">
+      <div className="bg-card rounded-xl p-6 border shadow-sm">
+        <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
+          <div className="flex items-center space-x-3 flex-1 min-w-0">
+            <div className="w-10 h-10 flex-shrink-0 bg-primary rounded-lg flex items-center justify-center shadow-sm">
+              <User className="w-5 h-5 text-primary-foreground" />
+            </div>
+            <div className="min-w-0">
+              <h1 className="text-xl font-bold text-foreground">
+                Mi Cuenta
+              </h1>
+              <p className="text-sm text-muted-foreground">
+                Gestiona tu información personal y configuración de la cuenta
+              </p>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 231 - 22
src/components/account/MedicalInfoSection.tsx

@@ -1,10 +1,11 @@
 "use client"
 
+import { useState } from "react"
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Textarea } from "@/components/ui/textarea"
-import { Heart } from "lucide-react"
+import { Heart, AlertCircle } from "lucide-react"
 
 interface MedicalInfoSectionProps {
   formData: {
@@ -19,10 +20,161 @@ interface MedicalInfoSectionProps {
   onInputChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
 }
 
+interface ValidationErrors {
+  phone?: string
+  emergencyContact?: string
+  address?: string
+  medicalHistory?: string
+  allergies?: string
+  currentMedications?: string
+}
+
+// Exportar función de validación para uso en el hook
+export const validateMedicalInfo = (formData: MedicalInfoSectionProps['formData']): { isValid: boolean; errors: ValidationErrors } => {
+  const errors: ValidationErrors = {}
+
+  // Validar teléfono
+  if (formData.phone) {
+    const phoneRegex = /^[\d\s()+-]+$/
+    if (!phoneRegex.test(formData.phone)) {
+      errors.phone = "Solo se permiten números, espacios, paréntesis, guiones y +"
+    } else {
+      const digits = formData.phone.replace(/\D/g, '')
+      if (digits.length > 0 && digits.length < 10) {
+        errors.phone = "El número debe tener al menos 9 dígitos"
+      }
+    }
+    if (formData.phone.length > 20) {
+      errors.phone = "Máximo 20 caracteres"
+    }
+  }
+
+  // Validar contacto de emergencia
+  if (formData.emergencyContact && formData.emergencyContact.length > 100) {
+    errors.emergencyContact = "Máximo 100 caracteres"
+  }
+
+  // Validar dirección
+  if (formData.address && formData.address.length > 200) {
+    errors.address = "Máximo 200 caracteres"
+  }
+
+  // Validar campos de texto
+  if (formData.medicalHistory && formData.medicalHistory.length > 1000) {
+    errors.medicalHistory = "Máximo 1000 caracteres"
+  }
+
+  if (formData.allergies && formData.allergies.length > 500) {
+    errors.allergies = "Máximo 500 caracteres"
+  }
+
+  if (formData.currentMedications && formData.currentMedications.length > 1000) {
+    errors.currentMedications = "Máximo 1000 caracteres"
+  }
+
+  return {
+    isValid: Object.keys(errors).length === 0,
+    errors
+  }
+}
+
 export default function MedicalInfoSection({
   formData,
   onInputChange
 }: MedicalInfoSectionProps) {
+  const [errors, setErrors] = useState<ValidationErrors>({})
+
+  // Validación de teléfono: solo números, espacios, paréntesis, guiones y +
+  const validatePhone = (value: string): string | undefined => {
+    if (!value) return undefined
+    
+    const phoneRegex = /^[\d\s()+-]+$/
+    if (!phoneRegex.test(value)) {
+      return "Solo se permiten números, espacios, paréntesis, guiones y +"
+    }
+    
+    // Verificar que tenga al menos 7 dígitos
+    const digits = value.replace(/\D/g, '')
+    if (digits.length > 0 && digits.length < 7) {
+      return "El número debe tener al menos 7 dígitos"
+    }
+    
+    if (value.length > 20) {
+      return "Máximo 20 caracteres"
+    }
+    
+    return undefined
+  }
+
+  // Validación de contacto de emergencia
+  const validateEmergencyContact = (value: string): string | undefined => {
+    if (!value) return undefined
+    
+    if (value.length > 100) {
+      return "Máximo 100 caracteres"
+    }
+    
+    return undefined
+  }
+
+  // Validación de dirección
+  const validateAddress = (value: string): string | undefined => {
+    if (!value) return undefined
+    
+    if (value.length > 200) {
+      return "Máximo 200 caracteres"
+    }
+    
+    return undefined
+  }
+
+  // Validación de campos de texto largos
+  const validateTextArea = (value: string, maxLength: number): string | undefined => {
+    if (!value) return undefined
+    
+    if (value.length > maxLength) {
+      return `Máximo ${maxLength} caracteres`
+    }
+    
+    return undefined
+  }
+
+  const handleValidatedChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
+    const { name, value } = e.target
+    let error: string | undefined
+
+    // Validar según el campo
+    switch (name) {
+      case 'phone':
+        error = validatePhone(value)
+        break
+      case 'emergencyContact':
+        error = validateEmergencyContact(value)
+        break
+      case 'address':
+        error = validateAddress(value)
+        break
+      case 'medicalHistory':
+        error = validateTextArea(value, 1000)
+        break
+      case 'allergies':
+        error = validateTextArea(value, 500)
+        break
+      case 'currentMedications':
+        error = validateTextArea(value, 1000)
+        break
+    }
+
+    // Actualizar errores
+    setErrors(prev => ({
+      ...prev,
+      [name]: error
+    }))
+
+    // Solo llamar onChange si no hay error crítico (permitir seguir escribiendo)
+    onInputChange(e)
+  }
+
   return (
     <Card>
       <CardHeader>
@@ -46,10 +198,17 @@ export default function MedicalInfoSection({
                 id="phone"
                 name="phone"
                 type="tel"
-                placeholder="+1 (555) 123-4567"
+                placeholder="096 028 3974"
                 value={formData.phone || ""}
-                onChange={onInputChange}
+                onChange={handleValidatedChange}
+                className={errors.phone ? "border-red-500" : ""}
               />
+              {errors.phone && (
+                <div className="flex items-start gap-1 text-xs text-red-600">
+                  <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                  <span>{errors.phone}</span>
+                </div>
+              )}
             </div>
 
             <div className="space-y-2">
@@ -58,7 +217,7 @@ export default function MedicalInfoSection({
                 id="gender"
                 name="gender"
                 value={formData.gender || ""}
-                onChange={onInputChange}
+                onChange={handleValidatedChange}
                 className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent text-sm bg-background"
               >
                 <option value="">Seleccionar...</option>
@@ -76,11 +235,21 @@ export default function MedicalInfoSection({
               <Input
                 id="emergencyContact"
                 name="emergencyContact"
-                type="tel"
+                type="text"
                 placeholder="Nombre y teléfono"
                 value={formData.emergencyContact || ""}
-                onChange={onInputChange}
+                onChange={handleValidatedChange}
+                className={errors.emergencyContact ? "border-red-500" : ""}
               />
+              {errors.emergencyContact && (
+                <div className="flex items-start gap-1 text-xs text-red-600">
+                  <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                  <span>{errors.emergencyContact}</span>
+                </div>
+              )}
+              <p className="text-xs text-muted-foreground">
+                Ejemplo: María López - +57 300 123 4567
+              </p>
             </div>
 
             <div className="space-y-2">
@@ -91,8 +260,15 @@ export default function MedicalInfoSection({
                 type="text"
                 placeholder="Calle, número, ciudad, código postal"
                 value={formData.address || ""}
-                onChange={onInputChange}
+                onChange={handleValidatedChange}
+                className={errors.address ? "border-red-500" : ""}
               />
+              {errors.address && (
+                <div className="flex items-start gap-1 text-xs text-red-600">
+                  <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                  <span>{errors.address}</span>
+                </div>
+              )}
             </div>
           </div>
         </div>
@@ -108,13 +284,24 @@ export default function MedicalInfoSection({
               name="medicalHistory"
               placeholder="Enfermedades previas, cirugías, condiciones crónicas, etc."
               value={formData.medicalHistory || ""}
-              onChange={onInputChange}
+              onChange={handleValidatedChange}
               rows={3}
-              className="resize-none"
+              className={`resize-none ${errors.medicalHistory ? "border-red-500" : ""}`}
             />
-            <p className="text-xs text-muted-foreground">
-              Ejemplo: Diabetes tipo 2, hipertensión, apendicectomía en 2020
-            </p>
+            <div className="flex justify-between items-start">
+              <p className="text-xs text-muted-foreground">
+                Ejemplo: Diabetes tipo 2, hipertensión, apendicectomía en 2020
+              </p>
+              <span className="text-xs text-muted-foreground">
+                {formData.medicalHistory?.length || 0}/1000
+              </span>
+            </div>
+            {errors.medicalHistory && (
+              <div className="flex items-start gap-1 text-xs text-red-600">
+                <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                <span>{errors.medicalHistory}</span>
+              </div>
+            )}
           </div>
 
           <div className="space-y-2">
@@ -124,13 +311,24 @@ export default function MedicalInfoSection({
               name="allergies"
               placeholder="Alergias a medicamentos, alimentos, o sustancias"
               value={formData.allergies || ""}
-              onChange={onInputChange}
+              onChange={handleValidatedChange}
               rows={2}
-              className="resize-none"
+              className={`resize-none ${errors.allergies ? "border-red-500" : ""}`}
             />
-            <p className="text-xs text-muted-foreground">
-              Ejemplo: Penicilina, mariscos, polen
-            </p>
+            <div className="flex justify-between items-start">
+              <p className="text-xs text-muted-foreground">
+                Ejemplo: Penicilina, mariscos, polen
+              </p>
+              <span className="text-xs text-muted-foreground">
+                {formData.allergies?.length || 0}/500
+              </span>
+            </div>
+            {errors.allergies && (
+              <div className="flex items-start gap-1 text-xs text-red-600">
+                <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                <span>{errors.allergies}</span>
+              </div>
+            )}
           </div>
 
           <div className="space-y-2">
@@ -140,13 +338,24 @@ export default function MedicalInfoSection({
               name="currentMedications"
               placeholder="Medicamentos que estás tomando actualmente"
               value={formData.currentMedications || ""}
-              onChange={onInputChange}
+              onChange={handleValidatedChange}
               rows={3}
-              className="resize-none"
+              className={`resize-none ${errors.currentMedications ? "border-red-500" : ""}`}
             />
-            <p className="text-xs text-muted-foreground">
-              Ejemplo: Metformina 850mg (2 veces al día), Losartán 50mg (1 vez al día)
-            </p>
+            <div className="flex justify-between items-start">
+              <p className="text-xs text-muted-foreground">
+                Ejemplo: Metformina 850mg (2 veces al día), Losartán 50mg (1 vez al día)
+              </p>
+              <span className="text-xs text-muted-foreground">
+                {formData.currentMedications?.length || 0}/1000
+              </span>
+            </div>
+            {errors.currentMedications && (
+              <div className="flex items-start gap-1 text-xs text-red-600">
+                <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                <span>{errors.currentMedications}</span>
+              </div>
+            )}
           </div>
         </div>
       </CardContent>

+ 45 - 4
src/hooks/useAccountForm.ts

@@ -3,6 +3,7 @@ import { useSession } from "next-auth/react"
 import { useRouter } from "next/navigation"
 import { notifications } from "@/lib/notifications"
 import { useProfileImageContext } from "@/contexts/ProfileImageContext"
+import { validateMedicalInfo } from "@/components/account/MedicalInfoSection"
 
 interface FormData {
   name: string
@@ -48,6 +49,7 @@ export function useAccountForm() {
   const [imageFile, setImageFile] = useState<File | null>(null)
   const [loadingImage, setLoadingImage] = useState(false)
   const [imageLoaded, setImageLoaded] = useState(false)
+  const [isUpdating, setIsUpdating] = useState(false) // Flag para prevenir race conditions
 
   const loadProfileImage = useCallback(async () => {
     if (!session?.user?.id || imageLoaded) return
@@ -79,7 +81,8 @@ export function useAccountForm() {
 
   // Cargar datos del usuario cuando la sesión esté disponible
   useEffect(() => {
-    if (session?.user) {
+    // No actualizar formData si estamos en medio de una actualización manual
+    if (session?.user && !isUpdating) {
       setFormData(prev => ({
         ...prev,
         name: session.user.name || "",
@@ -96,7 +99,7 @@ export function useAccountForm() {
         currentMedications: session.user.currentMedications || ""
       }))
     }
-  }, [session])
+  }, [session, isUpdating])
 
   // Cargar imagen de perfil cuando el usuario esté autenticado
   useEffect(() => {
@@ -170,9 +173,28 @@ export function useAccountForm() {
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
     setLoading(true)
+    setIsUpdating(true) // Activar flag para prevenir race condition
 
     try {
-      // Validaciones
+      // Validar información médica
+      const medicalValidation = validateMedicalInfo({
+        phone: formData.phone,
+        gender: formData.gender,
+        address: formData.address,
+        emergencyContact: formData.emergencyContact,
+        medicalHistory: formData.medicalHistory,
+        allergies: formData.allergies,
+        currentMedications: formData.currentMedications
+      })
+
+      if (!medicalValidation.isValid) {
+        const errorMessages = Object.values(medicalValidation.errors).join(', ')
+        notifications.profile.updateError(`Errores de validación: ${errorMessages}`)
+        setLoading(false)
+        return
+      }
+
+      // Validaciones de contraseña
       if (formData.newPassword && formData.newPassword !== formData.confirmPassword) {
         notifications.auth.passwordMismatch()
         setLoading(false)
@@ -243,9 +265,21 @@ export function useAccountForm() {
       if (response.ok) {
         notifications.profile.updated()
         
-        // Limpiar contraseñas
+        // Actualizar formData local con los datos guardados
         setFormData(prev => ({
           ...prev,
+          name: data.user.name || "",
+          lastname: data.user.lastname || "",
+          email: data.user.email || "",
+          phone: data.user.phone || "",
+          dateOfBirth: data.user.dateOfBirth ? new Date(data.user.dateOfBirth).toISOString().split('T')[0] : "",
+          gender: data.user.gender || "",
+          address: data.user.address || "",
+          emergencyContact: data.user.emergencyContact || "",
+          medicalHistory: data.user.medicalHistory || "",
+          allergies: data.user.allergies || "",
+          currentMedications: data.user.currentMedications || "",
+          // Limpiar contraseñas
           currentPassword: "",
           newPassword: "",
           confirmPassword: ""
@@ -279,12 +313,19 @@ export function useAccountForm() {
             loadProfileImage()
           }, 500)
         }
+
+        // Desactivar flag después de que la sesión se haya actualizado
+        setTimeout(() => {
+          setIsUpdating(false)
+        }, 500)
       } else {
         notifications.profile.updateError(data.error)
+        setIsUpdating(false)
       }
     } catch (error) {
       console.error("Error updating profile:", error)
       notifications.profile.updateError()
+      setIsUpdating(false)
     } finally {
       setLoading(false)
     }

+ 31 - 32
src/lib/auth.ts

@@ -104,43 +104,42 @@ export const authOptions: NextAuthOptions = {
     maxAge: config.session.jwtMaxAge, // Configurado desde variables de entorno
   },
   callbacks: {
-    async jwt({ token, user }) {
+    async jwt({ token, user, trigger, session }) {
+      // Durante login inicial - copiar todos los datos del usuario
       if (user) {
-        token.role = user.role;
-        token.id = user.id;
-        token.name = user.name;
-        token.lastname = user.lastname;
-        token.profileImage = user.profileImage;
-        token.isExternalAuth = user.isExternalAuth;
-        token.identificacion = user.identificacion;
-        token.dateOfBirth = user.dateOfBirth;
-        token.phone = user.phone;
-        token.gender = user.gender;
-        token.address = user.address;
-        token.emergencyContact = user.emergencyContact;
-        token.medicalHistory = user.medicalHistory;
-        token.allergies = user.allergies;
-        token.currentMedications = user.currentMedications;
+        return { ...token, ...user };
       }
+      
+      // Durante actualización con update() - solo actualizar campos que fueron enviados
+      if (trigger === "update" && session) {
+        return { ...token, ...session };
+      }
+      
       return token;
     },
     async session({ session, token }) {
-      if (token) {
-        session.user.id = token.id as string;
-        session.user.role = token.role;
-        session.user.name = token.name as string;
-        session.user.lastname = token.lastname as string;
-        session.user.profileImage = token.profileImage as string | undefined;
-        session.user.isExternalAuth = token.isExternalAuth as boolean | undefined;
-        session.user.identificacion = token.identificacion as string | undefined;
-        session.user.dateOfBirth = token.dateOfBirth as Date | undefined;
-        session.user.phone = token.phone as string | undefined;
-        session.user.gender = token.gender as string | undefined;
-        session.user.address = token.address as string | undefined;
-        session.user.emergencyContact = token.emergencyContact as string | undefined;
-        session.user.medicalHistory = token.medicalHistory as string | undefined;
-        session.user.allergies = token.allergies as string | undefined;
-        session.user.currentMedications = token.currentMedications as string | undefined;
+      // Copiar todos los campos del token a session.user (excepto los internos de JWT)
+      if (token && session.user) {
+        // Mantener la estructura de session.user y actualizar con datos del token
+        session.user = {
+          ...session.user,
+          id: token.id as string,
+          role: token.role as any,
+          name: token.name as string,
+          lastname: token.lastname as string,
+          email: token.email as string,
+          profileImage: token.profileImage as string | undefined,
+          isExternalAuth: token.isExternalAuth as boolean | undefined,
+          identificacion: token.identificacion as string | undefined,
+          dateOfBirth: token.dateOfBirth as Date | undefined,
+          phone: token.phone as string | undefined,
+          gender: token.gender as string | undefined,
+          address: token.address as string | undefined,
+          emergencyContact: token.emergencyContact as string | undefined,
+          medicalHistory: token.medicalHistory as string | undefined,
+          allergies: token.allergies as string | undefined,
+          currentMedications: token.currentMedications as string | undefined,
+        };
       }
       return session;
     }