from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status from app.database import get_pool from app.dependencies import get_db from app.auth import require_auth from app.models.paquete import ( PaqueteCreate, PaqueteUpdate, PaqueteResponse, FasePaqueteResponse, ManageAction, ) from app.models.common import ErrorResponse, OkResponse router = APIRouter(prefix="/paquetes", tags=["Paquetes"]) async def _build_paquete_response(conn, paquete_row: dict) -> dict: """Build a PaqueteResponse dict with nested fases and servicios.""" paquete_id = paquete_row["id"] fases_rows = await conn.fetch( 'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"' ' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC', paquete_id, ) fases: list[dict] = [] for fase in fases_rows: servicios_rows = await conn.fetch( 'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",' ' sp."createdAt", sp."updatedAt",' ' sc.id AS sc_id, sc.nombre AS sc_nombre, sc.descripcion AS sc_descripcion,' ' sc.fase AS sc_fase, sc."tipoPago" AS sc_tipoPago,' ' sc."precioBase" AS sc_precioBase, sc."tiempoEntrega" AS sc_tiempoEntrega,' ' sc."entregablesDefault" AS sc_entregablesDefault,' ' sc."categoriaId" AS sc_categoriaId, sc.variante AS sc_variante,' ' sc.activo AS sc_activo, sc.orden AS sc_orden,' ' sc."createdAt" AS sc_createdAt, sc."updatedAt" AS sc_updatedAt,' ' cat.id AS cat_id, cat.nombre AS cat_nombre, cat.descripcion AS cat_descripcion,' ' cat.color AS cat_color, cat.activo AS cat_activo, cat.orden AS cat_orden,' ' cat."createdAt" AS cat_createdAt, cat."updatedAt" AS cat_updatedAt' ' FROM "ServicioPaquete" sp' ' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"' ' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"' ' WHERE sp."fasePaqueteId" = $1', fase["id"], ) servicios: list[dict] = [] for s in servicios_rows: cat = None if s["cat_id"]: cat = { "id": s["cat_id"], "nombre": s["cat_nombre"], "descripcion": s["cat_descripcion"], "color": s["cat_color"], "activo": s["cat_activo"], "orden": s["cat_orden"], "createdAt": s["cat_createdAt"], "updatedAt": s["cat_updatedAt"], } servicios.append({ "id": s["id"], "servicioCatalogoId": s["servicioCatalogoId"], "fasePaqueteId": s["fasePaqueteId"], "createdAt": s["createdAt"], "updatedAt": s["updatedAt"], "servicio": { "id": s["sc_id"], "nombre": s["sc_nombre"], "descripcion": s["sc_descripcion"], "fase": s["sc_fase"], "tipoPago": s["sc_tipoPago"], "precioBase": s["sc_precioBase"], "tiempoEntrega": s["sc_tiempoEntrega"], "entregablesDefault": s["sc_entregablesDefault"], "categoriaId": s["sc_categoriaId"], "variante": s["sc_variante"], "activo": s["sc_activo"], "orden": s["sc_orden"], "createdAt": s["sc_createdAt"], "updatedAt": s["sc_updatedAt"], "categoriaRel": cat, }, }) fases.append({ "id": fase["id"], "paqueteId": fase["paqueteId"], "nombre": fase["nombre"], "orden": fase["orden"], "createdAt": fase["createdAt"], "updatedAt": fase["updatedAt"], "servicios": servicios, }) return { "id": paquete_row["id"], "nombre": paquete_row["nombre"], "descripcion": paquete_row["descripcion"], "activo": paquete_row["activo"], "createdAt": paquete_row["createdAt"], "updatedAt": paquete_row["updatedAt"], "fases": fases, } @router.get("", response_model=list[PaqueteResponse]) async def list_paquetes( _auth: dict = Depends(require_auth), conn=Depends(get_db), ): rows = await conn.fetch( 'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"' ' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC' ) results: list[dict] = [] for row in rows: results.append(await _build_paquete_response(conn, dict(row))) return results @router.post( "", response_model=PaqueteResponse, status_code=status.HTTP_201_CREATED, ) async def create_paquete( body: PaqueteCreate, _auth: dict = Depends(require_auth), conn=Depends(get_db), ): now = datetime.now(timezone.utc) paquete_row = await conn.fetchrow( 'INSERT INTO "Paquete" (id, nombre, descripcion, "createdAt", "updatedAt")' " VALUES (gen_random_uuid(), $1, $2, $3, $3)" ' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"', body.nombre, body.descripcion, now, ) paquete_id = paquete_row["id"] for fase in body.fases: await conn.execute( 'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")' " VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)", paquete_id, fase.nombre, fase.orden, now, ) return await _build_paquete_response(conn, dict(paquete_row)) @router.get( "/{paquete_id}", response_model=PaqueteResponse, responses={404: {"model": ErrorResponse}}, ) async def get_paquete( paquete_id: str, _auth: dict = Depends(require_auth), conn=Depends(get_db), ): row = await conn.fetchrow( 'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"' ' FROM "Paquete" WHERE id = $1', paquete_id, ) if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Paquete no encontrado", ) return await _build_paquete_response(conn, dict(row)) @router.put( "/{paquete_id}", response_model=PaqueteResponse, responses={404: {"model": ErrorResponse}}, ) async def update_paquete( paquete_id: str, body: PaqueteUpdate, _auth: dict = Depends(require_auth), conn=Depends(get_db), ): existing = await conn.fetchrow( 'SELECT id FROM "Paquete" WHERE id = $1', paquete_id ) if not existing: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Paquete no encontrado", ) updates: list[str] = [] params: list = [] idx = 1 if body.nombre is not None: updates.append(f"nombre = ${idx}") params.append(body.nombre) idx += 1 if body.descripcion is not None: updates.append(f"descripcion = ${idx}") params.append(body.descripcion) idx += 1 if body.activo is not None: updates.append(f"activo = ${idx}") params.append(body.activo) idx += 1 if not updates: row = await conn.fetchrow( 'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"' ' FROM "Paquete" WHERE id = $1', paquete_id, ) return await _build_paquete_response(conn, dict(row)) updates.append(f'"updatedAt" = ${idx}') params.append(datetime.now(timezone.utc)) idx += 1 params.append(paquete_id) row = await conn.fetchrow( f'UPDATE "Paquete" SET {", ".join(updates)} WHERE id = ${idx}' ' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"', *params, ) return await _build_paquete_response(conn, dict(row)) @router.delete( "/{paquete_id}", response_model=OkResponse, responses={404: {"model": ErrorResponse}}, ) async def delete_paquete( paquete_id: str, _auth: dict = Depends(require_auth), conn=Depends(get_db), ): result = await conn.execute( 'DELETE FROM "Paquete" WHERE id = $1', paquete_id ) if result == "DELETE 0": raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Paquete no encontrado", ) return OkResponse(ok=True) @router.post( "/{paquete_id}/manage", responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}, ) async def manage_paquete( paquete_id: str, body: ManageAction, _auth: dict = Depends(require_auth), conn=Depends(get_db), ): paquete = await conn.fetchrow( 'SELECT id FROM "Paquete" WHERE id = $1', paquete_id ) if not paquete: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Paquete no encontrado", ) now = datetime.now(timezone.utc) action = body.action if action == "addFase": if not body.nombre: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="El campo 'nombre' es requerido para addFase", ) row = await conn.fetchrow( 'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")' " VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)" ' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"', paquete_id, body.nombre, body.orden or 0, now, ) return dict(row), status.HTTP_201_CREATED if action == "updateFase": if not body.faseId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="El campo 'faseId' es requerido para updateFase", ) updates: list[str] = [] params: list = [] idx = 1 if body.nombre is not None: updates.append(f"nombre = ${idx}") params.append(body.nombre) idx += 1 if body.orden is not None: updates.append(f"orden = ${idx}") params.append(body.orden) idx += 1 if not updates: row = await conn.fetchrow( 'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"' ' FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2', body.faseId, paquete_id, ) if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Fase no encontrada", ) return dict(row) updates.append(f'"updatedAt" = ${idx}') params.append(now) idx += 1 params.extend([body.faseId, paquete_id]) row = await conn.fetchrow( f'UPDATE "FasePaquete" SET {", ".join(updates)}' f" WHERE id = ${idx} AND \"paqueteId\" = ${idx + 1}" ' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"', *params, ) if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Fase no encontrada", ) return dict(row) if action == "deleteFase": if not body.faseId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="El campo 'faseId' es requerido para deleteFase", ) result = await conn.execute( 'DELETE FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2', body.faseId, paquete_id, ) if result == "DELETE 0": raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Fase no encontrada", ) return {"ok": True} if action == "addServicio": if not body.servicioCatalogoId or not body.fasePaqueteId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para addServicio", ) fase = await conn.fetchrow( 'SELECT id FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2', body.fasePaqueteId, paquete_id, ) if not fase: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Fase no encontrada en este paquete", ) try: row = await conn.fetchrow( 'INSERT INTO "ServicioPaquete" (id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt")' " VALUES (gen_random_uuid(), $1, $2, $3, $3)" ' RETURNING id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt"', body.servicioCatalogoId, body.fasePaqueteId, now, ) except Exception as e: if "unique" in str(e).lower() or "duplicate" in str(e).lower(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Este servicio ya está asignado a esta fase", ) raise return dict(row), status.HTTP_201_CREATED if action == "removeServicio": if not body.servicioCatalogoId or not body.fasePaqueteId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para removeServicio", ) result = await conn.execute( 'DELETE FROM "ServicioPaquete"' ' WHERE "servicioCatalogoId" = $1 AND "fasePaqueteId" = $2', body.servicioCatalogoId, body.fasePaqueteId, ) if result == "DELETE 0": raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Servicio no encontrado en esta fase", ) return {"ok": True} raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Acción desconocida: '{action}'. Acciones válidas: addFase, updateFase, deleteFase, addServicio, removeServicio", )