Mesa247 · Backend / Full-stack de producto
Seis decisiones. Dos horas. Nada que construir.
Seis decisiones que alguien tomó de verdad, y que salieron bien o mal en producción.
No te pedimos construir nada: ni levantar un proyecto, ni un CRUD, ni maquetar una pantalla. Te sentamos en el asiento del ingeniero de guardia durante un día y te damos seis cosas que decidir.
En este trabajo casi nunca se empieza de cero. Se entra a un sistema que llevan diez años tocando muchas manos, con dinero de por medio y restaurantes atendiendo en vivo, y se decide qué cambiar, qué no tocar y qué preguntar antes de tocar nada. Eso es lo que queremos ver.
Varios de estos casos tienen una respuesta que suena bien y es incorrecta. Un razonamiento honesto que llega a una conclusión distinta a la nuestra vale más que una respuesta correcta sin sustento.
No buscamos la respuesta de libro. Buscamos tu criterio.En serio, dos. Si te toma cinco, la prueba está mal diseñada y queremos saberlo: dínoslo en tu entrega.
Es a propósito. Prioriza, entrega lo que puedas y escribe al final qué dejaste fuera y por qué. Esa lista la leemos con la misma atención que el resto.
Solo si en algún caso te resulta más rápido explicarte con cinco líneas. No compilamos nada: leemos la idea.
ChatGPT, Claude, Copilot, buscar, preguntarle a quien quieras. Sin restricciones. Es más: esas conversaciones son parte de la entrega, una por caso. Abajo está el porqué y el cómo.
Algunos casos están mal especificados a propósito. Si algo no se entiende o crees que un caso está mal planteado, escríbenos. Preguntar es parte de la prueba, no un punto en contra.
Si avanzas. Te pediremos que expliques por qué tu respuesta es correcta, qué caso no cubre y qué harías distinto con mil veces más datos. Entrega solo lo que puedas sostener tú.
Sin repositorio, sin capturas.
Un archivo. Para cada caso que respondas:
Y al cerrar, dos secciones cortas: lo que dejé fuera y por qué, y lo que me faltó saber.
.md · .txt · .pdf
Media a una página por caso. Si escribes cuatro, estás explicando de más.
Si usaste un asistente, mándanos la conversación de cada caso, por separado. Completa, tal cual salió, sin editarla ni maquillarla.
Si respondiste cuatro casos, esperamos cuatro conversaciones. Lo más simple es abrir un chat nuevo por caso; también vale uno solo si marcas dónde empieza cada uno.
Lo que no nos sirve es un bloque de tres horas sin saber qué parte corresponde a qué pregunta: ahí ya no podemos leer tu razonamiento sobre ese problema, que es justo lo que queremos ver.
Enlace para compartir por chat, exportación, o pegadas como anexo bajo un título por caso.
Si alguna tiene cosas personales o de otro trabajo, recórtalas y dínoslo.
Dos candidatos nos entregan la misma respuesta correcta. Uno sabía por qué; el otro aceptó lo primero que le dijeron y tuvo suerte. En el documento se ven idénticos. En la conversación no se parecen en nada.
Esa diferencia es exactamente lo que contratamos. Acá el modelo te va a dar todos los días una respuesta que suena perfecta y no lo es, porque no conoce este sistema: no sabe que esa tabla tiene 48 millones de filas, que ese endpoint lo llaman tres apps distintas, ni que mergear despliega. El criterio es lo que haces con esa respuesta plausible, y eso solo se ve en la conversación.
Cuando la leemos, buscamos seis cosas:
Nada de eso se puede fingir escribiendo bonito, y nada de eso aparece en el documento final.
Criterio técnico, experiencia y forma de pensar. Todo lo de abajo sale de ahí.
Si necesitas más tiempo por trabajo o por lo que sea, escríbenos y te lo damos: no evaluamos velocidad de calendario.
Y si algo de este documento no se entiende, escríbenos antes de entregar.
Los seis casos ocurren en la misma plataforma ficticia, que se parece bastante a la nuestra.
tests/, y dos son el ejemplo que trae el framework.Los nombres de tablas, archivos y locales de esta prueba son ficticios.
Te llega este PR. Eres el único revisor. Si lo apruebas, sale a producción en el siguiente merge.
| @@ -104,16 +104,24 @@ class AuthController extends Controller | ||
| 104 | 104 | public function login(Request $request) |
| 105 | 105 | { |
| 106 | 106 | $usuario = $request->input('usuario'); |
| 107 | 107 | $password = $request->input('password'); |
| 108 | 108 | $origen = $request->input('origen'); |
| 109 | 109 | |
| 110 | 110 | $cuenta = UsuarioPanel::where('Usuario', $usuario)->first(); |
| 111 | 111 | |
| 112 | 112 | if ($cuenta) { |
| 113 | if ($password != $cuenta->PasswordPlain) | |
| 114 | $cuenta = null; | |
| 113 | if ($origen == 'guardar_tarjeta' || $origen == 'borrar_tarjeta') { | |
| 114 | if ($password != $cuenta->Password) | |
| 115 | $cuenta = null; | |
| 116 | } else { | |
| 117 | if (! PasswordHasher::verify($password, $cuenta->Password, $cuenta->Salt)) | |
| 118 | $cuenta = null; | |
| 119 | } | |
| 115 | 120 | } |
| 116 | 121 | |
| 117 | 122 | if (! $cuenta) |
| 118 | 123 | return response()->json(['message' => 'Credenciales inválidas'], 401); |
| 119 | 124 | |
| 120 | 125 | return $this->emitirToken($cuenta); |
| 121 | 126 | } |
Datos adicionales del sistema, por si te sirven:
UsuarioPanel son las cuentas del Libro: dueños, administradores y anfitriones de los restaurantes. Unas 8.000 activas.UsuarioPanel.Password guarda el hash. Salt guarda la sal.emitirToken() devuelve un Bearer con 365 días de vigencia.Route::group(['middleware' => ['auth:api']], function () {
// ... rutas del Libro
});
Route::post('/auth/login', 'Api\Auth\AuthController@login');
Route::post('/auth/recuperar', 'Api\Auth\AuthController@recuperar');
¿Apruebas el PR? Escribe el comentario que dejarías en él.
Este es el archivo. Está recortado: son 640 líneas en total, te dejamos las partes relevantes con su numeración original.
| 130 | public function getList(Request $request) |
| 131 | { |
| 132 | $localId = $request->input('local_id'); |
| 133 | $fecha = $request->input('fecha'); |
| 134 | |
| 136 | $items = MenuAdicional::select('Id', 'Nombre', 'Precio', 'Obligatorio', |
| 138 | 'Minimo', 'Maximo', 'Orden', 'LocalId', 'EstadoId') |
| 141 | ->where('LocalId', $localId) |
| 142 | ->where('EstadoId', 1); |
| 143 | |
| 151 | if ($request->canal == 'widget') { |
| 152 | $items->whereIn('Id', [768, 769]); |
| 153 | } |
| 156 | $items->where('MinFecha', '<=', $fecha); |
| 164 | $items->where('Id', '!=', 595); |
| 176 | $items->where('Id', '!=', 767); |
| 195 | $itemsV2 = clone $items; |
| ··· | · · · |
| 210 | // stock consumido en el día |
| 211 | $stock = DB::select(" |
| 212 | SELECT MenuAdicionalId, SUM(Cantidad) AS usado |
| 213 | FROM ReservaMenuAdicional rma |
| 214 | INNER JOIN Reserva r ON r.Id = rma.ReservaId |
| 215 | WHERE r.LocalId = '$localId' |
| 216 | AND r.Fecha = '$fecha' |
| 217 | GROUP BY MenuAdicionalId |
| 218 | "); |
| ··· | · · · |
| 232 | return response()->json($items->orderBy('Orden')->get()); |
| 233 | } |
| ··· | · · · |
| 255 | public function getListV2(Request $request) |
| 256 | { |
| 257 | $localId = $request->input('local_id'); |
| 258 | $fecha = $request->input('fecha'); |
| 259 | |
| 261 | $items2 = MenuAdicional::select('Id', 'Nombre', 'Precio', 'Obligatorio', |
| 263 | 'Minimo', 'Maximo', 'Orden', 'LocalId', 'EstadoId') |
| 266 | ->where('LocalId', $localId) |
| 267 | ->where('EstadoId', 1); |
| ··· | · · · |
| 288 | $stock2 = DB::select(" |
| 289 | SELECT MenuAdicionalId, SUM(Cantidad) AS usado |
| 290 | FROM ReservaMenuAdicional rma |
| 291 | INNER JOIN Reserva r ON r.Id = rma.ReservaId |
| 292 | WHERE r.LocalId = '$localId' |
| 293 | AND r.Fecha = '$fecha' |
| 294 | GROUP BY MenuAdicionalId |
| 295 | "); |
| ··· | · · · |
| 340 | return response()->json($items2->orderBy('Orden')->get()); |
| 341 | } |
Sabemos, además, que getList la usa el widget y getListV2 la usan las dos apps móviles.
¿Qué entregas y en cuánto tiempo? Si crees que el ticket está mal planteado, dilo.
Te despierta una alerta. Alguien está enviando payloads de JavaScript por el campo nombre del widget de reservas. Los payloads se están guardando y se ejecutan cuando el restaurante abre su Libro por la mañana: el navegador del anfitrión ejecuta código del atacante.
Lo que tienes:
Cuatro caminos sobre la mesa:
nombre contenga <script, onerror, javascript: y unas 40 palabras más.nombre y responder al widget 200 OK con éxito, para que el formulario no se rompa delante del comensal.Te escribe la gerente comercial por chat, un miércoles a las 11 de la mañana:
Sabes esto del sistema:
LocalConfiguracion con pares clave/valor por local, y un panel interno donde alguien de soporte puede editarlos sin desplegar. Añadir una clave nueva a ese panel toma alrededor de medio día.if (in_array($localId, [...])) en el punto correcto toma unos 20 minutos, incluyendo el despliegue.Un compañero te pide revisar esto antes de mergear. Es un comando programado que envía la encuesta de satisfacción a quienes reservaron y ya terminaron de comer. Corre cada hora, una vez por cada uno de los cuatro países.
SELECT r.Id, r.Email, r.Nombre, r.Horario, l.Nombre AS Local FROM Reserva r INNER JOIN Local l ON l.Id = r.LocalId WHERE LEFT(r.Horario, 13) = ? -- se le pasa '2026-09-08 14' AND r.EstadoId = 2 AND l.PaisId = ?
Contexto:
Reserva tiene ~48 millones de filas y crece unas 25.000 al día.Reserva.Horario es DATETIME. Sus índices: PRIMARY (Id) y IX_Reserva_Local_Horario (LocalId, Horario).Local tiene 2.900 filas, con índice en PaisId.Te llega esto por el canal de soporte:
Y estas tres líneas de log, que es todo lo que te dieron:
2026-09-05 19:41:22 [warning] api.reserva.crear status=401 body={"message":"Unauthenticated."} origen=app_android local=1874
2026-09-05 19:41:23 [info] api.token.emitir usuario=reservador_1874 ok=true
2026-09-05 19:41:23 [warning] api.reserva.crear status=401 body={"message":"Unauthenticated."} origen=app_ios local=2201
Escribe lo que harías en los próximos 30 minutos. Si consideras que no puedes responder con lo que tienes, dilo y escribe exactamente qué necesitas y a quién se lo pides.