Skip to content

Commit 4b2cfa8

Browse files
committed
feat: bloquea pasaportes falsos y oculta emails a partners
1 parent 929055c commit 4b2cfa8

7 files changed

Lines changed: 219 additions & 58 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ PROJECT_STANDARDS.md
1515
api/check-brevo.ts
1616
api/count-estructuras.ts
1717
api/fix-all-brevo.ts
18+
api/check-marla.js
19+
api/check-marla.ts

api/src/authMiddleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const checkPartnerAccess = async (req: Request, res: Response, next: Next
7777
if (!access) {
7878
return res.status(403).json({
7979
error: 'Acceso temporal finalizado o no autorizado',
80-
message: 'Tu periodo de acceso a este evento ha terminado o aún no ha comenzado. Si crees que esto es un error, por favor contacta con irina.ichim@femcodersclub.com.',
80+
message: 'Tu periodo de acceso al portal ha concluido. Si necesitas renovar el acceso o tienes alguna duda, por favor ponte en contacto con Irina Ichim en irina.ichim@femcodersclub.com.',
8181
contact: 'irina.ichim@femcodersclub.com'
8282
});
8383
}

api/src/controllers/adminController.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,25 @@ export const syncPreview = async (req: Request, res: Response) => {
7979
});
8080

8181
const buyerMap = new Map<string, any>();
82-
const isValidDNI = (dni: string) => /^[XYZ0-9][0-9]{7}[TRWAGMYFPDXBNJZSQVHLCKE]$/i.test(dni || '');
82+
const isValidDNI = (dni: string, firstName: string = '', lastName: string = '') => {
83+
if (!dni || dni.trim() === '') return false;
84+
const clean = dni.replace(/\s+/g, '').toUpperCase();
85+
86+
if (/^[XYZ0-9][0-9]{7}[TRWAGMYFPDXBNJZSQVHLCKE]$/.test(clean)) return true;
87+
88+
const fName = firstName.replace(/\s+/g, '').toUpperCase();
89+
const lName = lastName.replace(/\s+/g, '').toUpperCase();
90+
if (fName.length >= 3 && clean.includes(fName)) return false;
91+
if (lName.length >= 3 && clean.includes(lName)) return false;
92+
93+
if (/^[A-Z0-9]{6,15}$/.test(clean) && /[A-Z]/.test(clean) && /[0-9]/.test(clean)) {
94+
const numCount = (clean.match(/[0-9]/g) || []).length;
95+
if (numCount >= 4 && !/[A-Z]{3,}/.test(clean)) {
96+
return true;
97+
}
98+
}
99+
return false;
100+
};
83101

84102
attendees.forEach(a => {
85103
const raw = a.toJSON() as any;
@@ -102,7 +120,7 @@ export const syncPreview = async (req: Request, res: Response) => {
102120
// Si alguno de los tickets ya fue notificado, marcamos al comprador como notificado
103121
if (raw.brevoNotified) b.isNotified = true;
104122

105-
const hasBadDni = !raw.dni || raw.dni.trim() === '' || !isValidDNI(raw.dni) || (raw.firstName || '').toLowerCase().includes('info requested');
123+
const hasBadDni = !raw.dni || raw.dni.trim() === '' || !isValidDNI(raw.dni, raw.firstName, raw.lastName) || (raw.firstName || '').toLowerCase().includes('info requested');
106124

107125
if (hasBadDni && !b.reasons.includes('DNI Incompleto')) {
108126
b.reasons.push('DNI Incompleto');

api/src/controllers/partnerController.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,32 @@ export const getPartnerRegistrations = async (req: Request, res: Response) => {
1414

1515
const groupedMap = new Map<string, any>();
1616

17-
// Acepta DNI/NIE español Y pasaportes extranjeros (>= 8 caracteres alfanuméricos mixtos)
18-
const isValidDNI = (dni: string) => {
17+
// Acepta DNI/NIE español Y pasaportes extranjeros (estricto)
18+
const isValidDNI = (dni: string, firstName: string = '', lastName: string = '') => {
1919
if (!dni || dni.trim() === '') return false;
20-
const clean = dni.trim();
21-
// DNI/NIE español
22-
if (/^[XYZ0-9][0-9]{7}[TRWAGMYFPDXBNJZSQVHLCKE]$/i.test(clean)) return true;
23-
// Pasaporte extranjero: >= 8 chars, contiene letras Y dígitos
24-
if (clean.length >= 8 && /[A-Za-z]/.test(clean) && /[0-9]/.test(clean)) return true;
20+
// Eliminamos espacios internos por si lo escriben como "Y 1234567 B"
21+
const clean = dni.replace(/\s+/g, '').toUpperCase();
22+
23+
// DNI/NIE español estricto
24+
if (/^[XYZ0-9][0-9]{7}[TRWAGMYFPDXBNJZSQVHLCKE]$/.test(clean)) return true;
25+
26+
// Si contiene el nombre o el apellido, es un intento de saltarse la validación (ej. "MCorales1")
27+
const fName = firstName.replace(/\s+/g, '').toUpperCase();
28+
const lName = lastName.replace(/\s+/g, '').toUpperCase();
29+
if (fName.length >= 3 && clean.includes(fName)) return false;
30+
if (lName.length >= 3 && clean.includes(lName)) return false;
31+
32+
// Pasaporte / ID extranjero: 6 a 15 caracteres estrictamente alfanuméricos
33+
if (/^[A-Z0-9]{6,15}$/.test(clean) && /[A-Z]/.test(clean) && /[0-9]/.test(clean)) {
34+
// Un pasaporte/ID real:
35+
// 1. Tiene al menos 4 números
36+
// 2. Normalmente no tiene más de 2 letras consecutivas (ej. AB123456). Si meten "Aak116419" o "AAA12345", lo rechazamos.
37+
const numCount = (clean.match(/[0-9]/g) || []).length;
38+
if (numCount >= 4 && !/[A-Z]{3,}/.test(clean)) {
39+
return true;
40+
}
41+
}
42+
2543
return false;
2644
};
2745

@@ -50,7 +68,7 @@ export const getPartnerRegistrations = async (req: Request, res: Response) => {
5068
existing.guests.push(guestName);
5169
}
5270
// Si el invitado tiene DNI inválido, lo marcamos
53-
if (!isValidDNI(raw.dni || '')) {
71+
if (!isValidDNI(raw.dni || '', raw.firstName, raw.lastName)) {
5472
existing.hasGuestWithBadDni = true;
5573
}
5674
}
@@ -67,7 +85,7 @@ export const getPartnerRegistrations = async (req: Request, res: Response) => {
6785
data.lastName = raw.orderLastName || raw.lastName;
6886

6987
const dniMissing = !data.dni || data.dni.trim() === '';
70-
const dniInvalid = !dniMissing && !isValidDNI(data.dni || '');
88+
const dniInvalid = !dniMissing && !isValidDNI(data.dni || '', raw.firstName, raw.lastName);
7189

7290
data.alerts = {
7391
isInfoRequested: isPlaceholder,

api/update-infojobs-access.sql

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
-- ============================================================
2+
-- Script: Actualizar endDate de InfoJobs a 27 de Marzo de 2026
3+
-- Ejecutar en Railway (MySQL) antes del 28 de Marzo de 2026
4+
-- ============================================================
5+
6+
-- 1. Primero VERIFICAR cuál es el registro antes de modificar:
7+
SELECT
8+
pa.id,
9+
p.name,
10+
p.slug,
11+
pa.startDate,
12+
pa.endDate,
13+
pa.canDownloadFullData
14+
FROM partner_access pa
15+
JOIN partners p ON pa.partnerId = p.id
16+
WHERE p.slug = 'infojobs' OR LOWER(p.name) LIKE '%infojobs%';
17+
18+
-- 2. Si el resultado anterior es correcto, ejecutar el UPDATE:
19+
UPDATE partner_access pa
20+
JOIN partners p ON pa.partnerId = p.id
21+
SET pa.endDate = '2026-03-27 23:59:59'
22+
WHERE p.slug = 'infojobs' OR LOWER(p.name) LIKE '%infojobs%';
23+
24+
-- 3. Confirmar el cambio:
25+
SELECT
26+
pa.id,
27+
p.name,
28+
p.slug,
29+
pa.startDate,
30+
pa.endDate
31+
FROM partner_access pa
32+
JOIN partners p ON pa.partnerId = p.id
33+
WHERE p.slug = 'infojobs' OR LOWER(p.name) LIKE '%infojobs%';

web/src/App.tsx

Lines changed: 99 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,12 @@ const App: React.FC = () => {
6666
// FemCoders (Admin) siempre tiene acceso a todo.
6767
const isFemCoders = data?.partner?.toLowerCase().includes('femcoders') || data?.partner?.toLowerCase().includes('admin');
6868

69-
// InfoJobs solo puede exportar el día del evento (26 de Marzo).
69+
// InfoJobs puede exportar desde el 25 hasta el 27 de Marzo de 2026.
7070
const isExportWindowOpen = () => {
7171
const today = new Date();
72-
// 26 de Marzo de 2026 (JS: 2026, 2, 26)
73-
return today.getFullYear() === 2026 &&
74-
today.getMonth() === 2 &&
75-
today.getDate() === 26;
72+
const start = new Date(2026, 2, 25, 0, 0, 0); // 25 Mar 2026 inicio
73+
const end = new Date(2026, 2, 27, 23, 59, 59); // 27 Mar 2026 fin de día
74+
return today >= start && today <= end;
7675
};
7776

7877
const canExport = isFemCoders || isExportWindowOpen();
@@ -283,15 +282,21 @@ const App: React.FC = () => {
283282

284283
const handleExportCSV = () => {
285284
if (!data || !canExport) return;
286-
const csvHeader = 'Nombre,Apellidos,Email,DNI,Acompañantes,Estado\n';
285+
// InfoJobs no recibe emails por cumplimiento RGPD
286+
const csvHeader = isFemCoders
287+
? 'Nombre,Apellidos,Email,DNI,Acompañantes,Estado\n'
288+
: 'Nombre,Apellidos,DNI,Entradas,Acompañantes,Estado\n';
287289
const csvRows = filteredAttendees.map(a => {
288-
const email = a.orderEmail || a.email || '';
289290
const dni = a.dni || (a.alerts.isInfoRequested ? 'PENDIENTE' : 'N/A');
290291
const guests = a.guests ? a.guests.join(' | ') : '';
291292
const status = (a.isIncomplete || a.alerts.dniInvalid) ? 'Incompleto' : 'Validado';
292293
const cleanFName = (a.firstName || '').replace(/,/g, '');
293294
const cleanLName = (a.lastName || '').replace(/,/g, '');
294-
return `"${cleanFName}","${cleanLName}","${email}","${dni}","${guests}","${status}"`;
295+
if (isFemCoders) {
296+
const email = a.orderEmail || a.email || '';
297+
return `"${cleanFName}","${cleanLName}","${email}","${dni}","${guests}","${status}"`;
298+
}
299+
return `"${cleanFName}","${cleanLName}","${dni}","${a.ticketCount}","${guests}","${status}"`;
295300
}).join('\n');
296301

297302
const blob = new Blob([new Uint8Array([0xEF, 0xBB, 0xBF]), csvHeader + csvRows], { type: 'text/csv;charset=utf-8;' });
@@ -306,15 +311,20 @@ const App: React.FC = () => {
306311

307312
const handleExportExcel = () => {
308313
if (!data || !canExport) return;
309-
const worksheet = XLSX.utils.json_to_sheet(filteredAttendees.map(a => ({
310-
Nombre: a.firstName || '',
311-
Apellidos: a.lastName || '',
312-
Email: a.orderEmail || a.email || '',
313-
'DNI / ID': a.dni || (a.alerts.isInfoRequested ? 'PENDIENTE' : 'N/A'),
314-
Estado: (a.isIncomplete || a.alerts.dniInvalid) ? 'Incompleto' : 'Validado',
315-
Entradas: a.ticketCount,
316-
Acompañantes: a.guests ? a.guests.join(', ') : ''
317-
})));
314+
// InfoJobs no recibe emails por cumplimiento RGPD
315+
const rows = filteredAttendees.map(a => {
316+
const base = {
317+
Nombre: a.firstName || '',
318+
Apellidos: a.lastName || '',
319+
'DNI / ID': a.dni || (a.alerts.isInfoRequested ? 'PENDIENTE' : 'N/A'),
320+
Entradas: a.ticketCount,
321+
Acompañantes: a.guests ? a.guests.join(', ') : '',
322+
Estado: (a.isIncomplete || a.alerts.dniInvalid) ? 'Incompleto' : 'Validado'
323+
};
324+
if (isFemCoders) return { ...base, Email: a.orderEmail || a.email || '' };
325+
return base;
326+
});
327+
const worksheet = XLSX.utils.json_to_sheet(rows);
318328
const eventTitle = "Estructuras en Movimiento: mujeres que transforman el futuro";
319329
const workbook = XLSX.utils.book_new();
320330
XLSX.utils.book_append_sheet(workbook, worksheet, "Asistentes");
@@ -330,19 +340,29 @@ const App: React.FC = () => {
330340
};
331341

332342
const doc = new jsPDF({ orientation: 'l', unit: 'mm', format: 'a4' }) as any;
333-
334-
const tableColumn = ["#", "Titular / Comprador", "Acompañantes", "Email", "DNI / ID", "Entradas", "Estado"];
335-
const tableRows = filteredAttendees.map((a, i) => [
336-
i + 1,
337-
cleanForPDF(`${a.firstName} ${a.lastName}`),
338-
a.guests ? cleanForPDF(a.guests.join(', ')) : '',
339-
a.orderEmail || a.email,
340-
a.dni || (a.alerts.isInfoRequested ? 'PENDIENTE' : 'N/A'),
341-
a.ticketCount,
342-
(a.isIncomplete || a.alerts.dniInvalid) ? 'Incompleto' : 'Validado'
343-
]);
344-
345343
const eventTitle = "Estructuras en Movimiento: mujeres que transforman el futuro";
344+
345+
// InfoJobs no recibe emails por cumplimiento RGPD
346+
const tableColumn = isFemCoders
347+
? ["#", "Titular / Comprador", "Acompañantes", "Email", "DNI / ID", "Entradas", "Estado"]
348+
: ["#", "Titular / Comprador", "Acompañantes", "DNI / ID", "Entradas", "Estado"];
349+
350+
const tableRows = filteredAttendees.map((a, i) => {
351+
const base = [
352+
i + 1,
353+
cleanForPDF(`${a.firstName} ${a.lastName}`),
354+
a.guests ? cleanForPDF(a.guests.join(', ')) : '',
355+
a.dni || (a.alerts.isInfoRequested ? 'PENDIENTE' : 'N/A'),
356+
a.ticketCount,
357+
(a.isIncomplete || a.alerts.dniInvalid) ? 'Incompleto' : 'Validado'
358+
];
359+
if (isFemCoders) {
360+
// Insertar email en posición 3 (después de acompañantes)
361+
base.splice(3, 0, a.orderEmail || a.email || '');
362+
}
363+
return base;
364+
});
365+
346366
doc.setFont('helvetica', 'bold');
347367
doc.setFontSize(18);
348368
doc.setTextColor(71, 55, 187);
@@ -354,38 +374,71 @@ const App: React.FC = () => {
354374
doc.text(`Evento: ${eventTitle}`, 14, 22);
355375
doc.text(`Exportado: ${new Date().toLocaleDateString()}`, 14, 27);
356376

377+
// Ajuste de columnas según si se incluye email o no
378+
const columnStyles = isFemCoders
379+
? {
380+
0: { cellWidth: 12, halign: 'center' },
381+
1: { cellWidth: 50 },
382+
2: { cellWidth: 45 },
383+
3: { cellWidth: 55 },
384+
4: { cellWidth: 30, halign: 'center' },
385+
5: { cellWidth: 20, halign: 'center' },
386+
6: { cellWidth: 25, halign: 'center' }
387+
}
388+
: {
389+
0: { cellWidth: 12, halign: 'center' },
390+
1: { cellWidth: 70 },
391+
2: { cellWidth: 70 },
392+
3: { cellWidth: 40, halign: 'center' },
393+
4: { cellWidth: 25, halign: 'center' },
394+
5: { cellWidth: 30, halign: 'center' }
395+
};
396+
357397
doc.autoTable({
358398
head: [tableColumn],
359399
body: tableRows,
360400
startY: 30,
361401
theme: 'grid',
362402
styles: { fontSize: 9, cellPadding: 3, font: 'helvetica', valign: 'middle', overflow: 'linebreak' },
363403
headStyles: { fillColor: [71, 55, 187], textColor: [255, 255, 255], fontStyle: 'bold', halign: 'center' },
364-
columnStyles: {
365-
0: { cellWidth: 12, halign: 'center' },
366-
1: { cellWidth: 50 },
367-
2: { cellWidth: 50 },
368-
3: { cellWidth: 65 },
369-
4: { cellWidth: 30, halign: 'center' },
370-
5: { cellWidth: 20, halign: 'center' },
371-
6: { cellWidth: 25, halign: 'center' }
372-
}
404+
columnStyles
373405
});
374406

375407
doc.save(`asistentes_${eventTitle.replace(/[: ]/g, '_')}.pdf`);
376408
};
377409

378410
if (loading) return <div className="loading-state">Cargando portal seguro...</div>;
411+
// Detectar si el error es de acceso expirado
412+
const isExpiredAccess = error?.toLowerCase().includes('periodo de acceso') || error?.toLowerCase().includes('acceso temporal finalizado');
413+
379414
if (error) return (
380415
<div className="error-container">
381416
<div className="error-card">
382-
<div className="error-icon">⚠️</div>
383-
<h2>Aviso del Sistema</h2>
384-
<p>{error}</p>
385-
<div className="error-actions">
386-
<button onClick={handleLogout} className="btn-secondary">Reintentar / Cambiar Código</button>
387-
<a href="mailto:irina.ichim@femcodersclub.com" className="btn-primary">Soporte Técnico</a>
388-
</div>
417+
{isExpiredAccess ? (
418+
<>
419+
<div className="error-icon">🔒</div>
420+
<h2>Acceso Finalizado</h2>
421+
<p>Tu periodo de acceso al portal ha concluido. Muchas gracias por la colaboración durante el evento.</p>
422+
<p className="error-contact-lead">Si necesitas acceder de nuevo o tienes alguna duda, ponte en contacto con:</p>
423+
<a href="mailto:irina.ichim@femcodersclub.com" className="error-contact-pill">
424+
<span className="contact-icon">✉️</span>
425+
<span>irina.ichim@femcodersclub.com</span>
426+
</a>
427+
<div className="error-actions error-actions-spaced">
428+
<button onClick={handleLogout} className="btn-secondary">Volver al inicio</button>
429+
</div>
430+
</>
431+
) : (
432+
<>
433+
<div className="error-icon">⚠️</div>
434+
<h2>Aviso del Sistema</h2>
435+
<p>{error}</p>
436+
<div className="error-actions">
437+
<button onClick={handleLogout} className="btn-secondary">Reintentar / Cambiar Código</button>
438+
<a href="mailto:irina.ichim@femcodersclub.com" className="btn-primary">Soporte Técnico</a>
439+
</div>
440+
</>
441+
)}
389442
</div>
390443
</div>
391444
);
@@ -555,7 +608,7 @@ const App: React.FC = () => {
555608
<button onClick={handleExportExcel} className="btn-export excel" disabled={!canExport}>Excel</button>
556609
<button onClick={handleExportPDF} className="btn-export pdf" disabled={!canExport}>PDF</button>
557610
</div>
558-
{!canExport && <div className="export-lock-notice">🔒 Exportación disponible solo el 26 de marzo</div>}
611+
{!canExport && <div className="export-lock-notice">🔒 Exportación disponible del 25 al 27 de marzo</div>}
559612
<div className="rows-selector">
560613
<span>Mostrar:</span>
561614
<select

web/src/index.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,43 @@ tr:hover td {
593593
justify-content: center;
594594
}
595595

596+
.error-actions-spaced {
597+
margin-top: 24px;
598+
}
599+
600+
/* Pantalla de acceso expirado / finalizado */
601+
.error-contact-lead {
602+
font-size: 0.95rem;
603+
color: var(--color-text-muted);
604+
margin-bottom: 0.75rem !important;
605+
}
606+
607+
.error-contact-pill {
608+
display: inline-flex;
609+
align-items: center;
610+
gap: 8px;
611+
background: linear-gradient(135deg, #fff0f6, #f0f9ff);
612+
border: 1px solid rgba(176, 36, 118, 0.2);
613+
color: var(--brand-magenta);
614+
font-weight: 700;
615+
font-size: 0.95rem;
616+
padding: 0.7rem 1.4rem;
617+
border-radius: 50px;
618+
text-decoration: none;
619+
transition: all 0.25s ease;
620+
box-shadow: 0 4px 12px rgba(176, 36, 118, 0.08);
621+
}
622+
623+
.error-contact-pill:hover {
624+
transform: translateY(-2px);
625+
box-shadow: 0 8px 20px rgba(176, 36, 118, 0.18);
626+
background: linear-gradient(135deg, #ffe4f0, #e0f2fe);
627+
}
628+
629+
.contact-icon {
630+
font-size: 1.1rem;
631+
}
632+
596633
.btn-primary, .btn-secondary {
597634
display: inline-block;
598635
padding: 0.8rem 1.8rem;

0 commit comments

Comments
 (0)