@c80/types
v3.4.0
Published
Contrato de tipos compartido del ecosistema barman (enums + interfaces de dominio + DTOs de WebSocket). Fuente unica de verdad para zerath-back, vulcan-arm y las apps del monorepo ngx-c80 (kyron, midas, chillbox, kiosk).
Readme
@c80/types
Contrato de tipos compartido del ecosistema barman. Lib TS pura (framework-agnostica: sin Angular, sin Nest, cero dependencias de runtime). Describe EXACTAMENTE lo que cruza un proceso por HTTP y WS.
Consumidores:
zerath-back— backend NestJS (dueno del contrato); paquete npm con pin exacto.vulcan-arm— controlador del brazo; paquete npm con pin exacto.kyron,midas,chillbox— apps del monorepo, por path (tsconfig.base.json).
Las REGLAS del contrato (fechas ISO, DTO anclado, errores con code, nombres) estan en la cabecera
de src/index.ts. Las reglas de NEGOCIO (redondeos, puntos, volumen, modos) NO son tipos: viven en
@c80/domain.
Contenido
Solo interface + enum + aliases. El fuente esta partido por DOMINIO en src/lib/ y el barrel
src/index.ts reexporta TODO (la API publica no depende del archivo en que viva cada tipo; los
imports internos llevan extension .js, obligatoria para ESM):
common—IsoDateString, FK, ActionResponseDto, PaginatedResponse,ApiErrorResponse(body de error).auth— Permission, Role, PublicUser/PublicBoxInfo, LoginResponse, GoogleLoginRequest, codigos de cuenta.user— AdminUser, OperatorProfile (perfil fiscal/contractual del operador B2B) y el equipo del operador.box— Box, BoxClientResponse, configuraciones OneToOne, Driver + DriverUpsertRequest, estado operativo y sesion de box (QR).config— GlobalConfiguration, feature flags (FeatureFlagKey/FeatureFlag) y GameId.catalog— insumos, dispensadores (+ calibracion y lecturas de nivel), Product/Recipe/Part y sus requests, ratings, favoritos.task— Task, Movement (+Drivers), MovementStatus/StepType, TaskStats, updates de movement.ws— salas/eventos del canal WS, ChatMessage, mantenimiento y jog, telemetria, BOX_HEALTH.purchase— saga de servido, DTOs de canje (chillbox), PurchaseErrorCode y listado admin.invoice— facturacion electronica ARCA (InvoiceStatus, comprobante publico/admin).recharge— preferencia de pago (MercadoPago), historial, config global, admin.wallet— billetera del operador B2B (movimientos, resumen de liquidacion, payouts).stats— pasivo de puntos, estadisticas de ventas, economia de puntos y juegos.terms— TyC versionados por audiencia (CLIENT/OPERATOR).consumer-request— botones de arrepentimiento/baja (Disp. 954/2025).worklog— bitacora de agentes (entregables con ciclo de vida).
3.4.0 — evidencia de lo servido y motivo de la pausa (2026-08-29)
MINOR aditivo: dos interfaces nuevas para la evidencia de la venta, una para el body del toggle
de barra, un campo en AdminPurchase y otro (opcional) en BoxStatus. No rompe 3.3.0.
Cierra la ultima deuda de contrato viva: zerath implemento las dos medidas de la excepcion legal al
congelamiento (D62) tipandolas LOCALMENTE, que es el "DTO gemelo sin ancla" que prohiben las reglas
de la cabecera de src/index.ts. Aca vuelven a tener una sola declaracion.
Nuevo — composicion servida (zerath 961ed2c)
RecipeSnapshotPart y RecipeSnapshot (purchase.ts) + AdminPurchase.recipeSnapshot:
RecipeSnapshot | null, que viaja en GET /api/purchase:
interface RecipeSnapshot {
productId: string;
productName: string;
hasIce: boolean;
parts: RecipeSnapshotPart[]; // { ingredientId, ingredientName, isAlcoholic, doses, unitMl }
}La receta es mutable y la venta apunta al PRODUCTO, no a una version de la receta: sin snapshot una venta de marzo termina describiendo la receta de hoy. El art. 40 de la Ley 24.240 pide una constancia anterior al reclamo, asi que la composicion se congela al canjear y se lee tal cual (no se recalcula ni se completa desde el catalogo actual, que es de lo que justamente no hay que fiarse).
Dos decisiones que conviene no re-discutir:
- No es
Reciperecortada.Recipees el catalogo VIVO (editable, con ids de parte y de dispensador); esto es evidencia congelada. Compartir el tipo ataria la evidencia a los cambios del catalogo. isAlcoholicpor PARTE, no soloProduct.isAlcoholic. Ante una intoxicacion la pregunta es cuanto alcohol se sirvio; y el flag del insumo tambien se edita despues del canje.
El campo es requerido y nullable (RecipeSnapshot | null), no opcional: zerath lo emite siempre
—una sola funcion de mapeo para staff y para operador scopeado— y vale null en las ventas
anteriores a la columna. Que el null sea explicito obliga al consumidor a decidir que muestra; en
kyron se informa "sin registro de composicion", no una lista vacia.
Nuevo — motivo de la pausa de barra (zerath fbff8f8)
ToggleBoxServingPausedRequest { reason?: string } (box.ts) — body OPCIONAL de
PATCH /box/:id/serving-paused. El endpoint sigue aceptando {}, asi que sumarlo no rompe a quien
ya lo llamaba sin cuerpo; quien reanuda tambien puede explicarse. Tope del server: 200 caracteres
(pasarse es HTTP 400). Es una NOTA operativa corta ("sospecha de contaminacion en el dispensador 3"),
no un descargo: acotarla evita que el campo se vuelva un cajon de texto libre con el riesgo de PII
que eso trae. El motivo NO vuelve por ninguna respuesta: queda en el historial de pausas de zerath.
BoxStatus.servingPausedAt?: IsoDateString | null — desde cuando esta pausada la barra.
Va en BoxStatus y no en Box, por donde viaja de verdad el dato: Box refleja
BoxResponseDto, que deja fuera a proposito lo operativo de la pausa (servingPausedBy tampoco esta
ahi), y los paneles leen el estado de la barra del GET /chillbox/box/:id/status, no del box. Al
estar en BoxStatus lo hereda BoxLiveStatus (Omit<BoxStatus, 'health'>), o sea el evento WS
BOX_STATUS, que es como llega el estado en vivo a kyron y midas.
Es opcional porque hoy zerath no lo emite: la columna box.serving_paused_at existe y se
escribe, pero ningun mapeo la copia al cable (ver migracion). Los paneles ya lo muestran cuando
llegue; hasta entonces simplemente no pintan esa linea.
Lo que NO entra: el historial de pausas
box_serving_pause_event (la tabla con cada pausa, su motivo, su origen y su actor) no se tipa:
zerath no la expone por ningun endpoint. El contrato tipa el CABLE, no el esquema. Cuando exista un
GET de historial —para el reclamo o el siniestro que motivo la tabla— entra con su *Response y su
paginado, no antes.
Notas de migracion — zerath
Compila sin cambios (los tipos son nuevos y los campos que agrega ya los emite). Lo que hay que hacer al pinear:
- Anclar la composicion: borrar
src/common/types/recipe-snapshot.types.tse importarRecipeSnapshot/RecipeSnapshotPartde@c80/types(entityPurchase,AdminPurchaseResponseDto,recipe-to-task-generator.service.ts,purchase.service.ts).AdminPurchaseResponseDtopuede ademas dejar de re-listarrecipeSnapshot: ya lo hereda deAdminPurchase. - Anclar el body del toggle:
ToggleBoxServingPausedDto implements ToggleBoxServingPausedRequest(el DTO con class-validator encima, como manda la regla del contrato). ElMaxLength(200)se queda en zerath: es la validacion, no el tipo. - Emitir
servingPausedAt(falta hoy, es lo unico que bloquea la pantalla de midas): agregarservingPausedAt: truealselectyservingPausedAt: toIso(box.servingPausedAt)al objeto de retorno enpurchase-query.service.ts(getBoxStatusForCatalog) y enbox-live-status.service.ts(snapshot deBOX_STATUS). Sin eso midas muestra la barra pausada pero no desde cuando. - Pin:
"@c80/types": "3.4.0".@c80/domainno cambia (sigue1.1.0; su peer>=3.1.0 <4.0.0acepta 3.4.0 sin republicar).
Del lado de las apps ya esta hecho (por path): kyron muestra la composicion en Ventas y midas manda el motivo al pausar.
3.3.0 — la contraseña regenerada del equipo entra al contrato (D55, 2026-08-29)
MINOR aditivo: una interfaz nueva, nada mas. No rompe 3.2.0.
Nuevo
OperatorTeamPasswordResetResponse (user.ts) — respuesta de
POST /api/midas/team/:id/password (permiso OPERATOR_TEAM_MANAGE), el endpoint con el que el
operador le regenera la contraseña inicial a un miembro que perdio la suya:
interface OperatorTeamPasswordResetResponse extends OperatorTeamMember {
password: string;
}Se define extendiendo OperatorTeamMember, no re-listando sus campos: el server devuelve el
miembro completo y le adosa la contraseña, asi que si el miembro cambia, esta respuesta cambia con
el.
password viaja UNA sola vez, en el cuerpo de esta respuesta, y no se persiste en claro en
ningun lado: zerath guarda solo el hash y ningun GET la expone. Quien la recibe la muestra en el
acto para que el operador se la entregue a la persona; si se pierde, el unico camino es regenerarla
otra vez. No debe loguearse ni quedar en el estado de la app mas alla del momento en que se muestra.
Por que un minor por un tipo. El endpoint se implemento despues de 3.2.0 y quedo tipado LOCAL en
los dos extremos — OperatorTeamPasswordResponse en midas y OperatorTeamPasswordResetResponseDto
en zerath —: dos declaraciones del mismo shape de cable, que es exactamente el "DTO gemelo sin
ancla" que prohiben las reglas del contrato (cabecera de src/index.ts). Y ya habian driftado: la
de midas declaraba solo { password } mientras el server devolvia el miembro entero. Se cierra con
el tipo aca y las dos declaraciones locales borradas.
Notas de migracion — zerath
Compila sin cambios (el tipo es nuevo y nadie lo referencia todavia). Lo que hay que hacer:
- Anclar el DTO:
OperatorTeamPasswordResetResponseDtodeja de ser un shape suelto y pasa aimplements OperatorTeamPasswordResetResponse(o el objeto de retorno consatisfies, si no necesita ser una clase), sin re-listar los campos deOperatorTeamMember. Si el DTO hoy declara solopassword, ampliarlo a lo que el endpoint realmente devuelve — elHTTP 200ya manda{ id, name, email, enabled, createdAt, lastLoginAt, password }— o achicar la respuesta; lo que no puede seguir es que el tipo y el cable digan cosas distintas. - Pin:
"@c80/types": "3.3.0".@c80/domainno cambia (sigue1.1.0; su peer>=3.1.0 <4.0.0acepta 3.3.0 sin republicar).
Del lado de midas ya esta hecho (por path): el servicio del equipo importa el tipo del contrato y la declaracion local se borro.
3.2.0 — permisos finos del box y equipo del operador (D53/D55, 2026-08-29)
MINOR aditivo: tres miembros nuevos del enum Permission y tres interfaces nuevas. No rompe
3.1.0 (un enum que crece y tipos nuevos no invalidan a ningun consumidor). Viaja con la oleada C-ui,
que no toca el contrato.
Nuevo
D53 — BOX_SELLING_WINDOW_UPDATE y BOX_STAFF_PIN_UPDATE (auth.ts):
Permission.BOX_SELLING_WINDOW_UPDATE— lo exigePUT /api/box/:id/selling-window(UpdateBoxSellingWindowRequest, D50).Permission.BOX_STAFF_PIN_UPDATE— lo exigePUT /api/box/:id/staff-pin(SetBoxStaffPinRequest, D51).
Quien los tiene: el staff de kyron (roles internos, alcance global) y el rol Operator, este
acotado a SUS boxes por el filtro de scope de zerath, igual que BOX_SERVING_TOGGLE. El permiso
habilita la ACCION; el box sigue decidiendolo el scope.
Por que no alcanzaba BOX_UPDATE: ese permiso abre el PUT /box/:id generico — renombrar,
relocalizar, reasignar ownerId —, que es administracion de plataforma y no del local. Filtrar
campos por rol dentro de un mismo DTO es fragil; un permiso por accion es la forma barata de minimo
privilegio. PUT /box/:id sigue siendo interno (BOX_WRITE/BOX_UPDATE de staff global).
D55 — equipo self-service del operador B2B (auth.ts, user.ts):
Permission.OPERATOR_TEAM_MANAGE— lo exigenGET/POST/PATCH /api/midas/team. Es solo para el rol Operator; el staff de kyron administra usuarios conUSER_*, no con este.OperatorTeamMember { id, name, email, enabled, createdAt, lastLoginAt }— la vista del equipo en midas.CreateOperatorTeamMemberRequest { name, email, password }yUpdateOperatorTeamMemberRequest { enabled?, name? }.
La invariante de seguridad: el rol y el scope NO son parametros. El alta de usuarios es la superficie clasica de escalacion de privilegios, asi que ninguno de los dos requests los lleva y el server no los acepta de ningun modo:
- El rol lo FUERZA el server: todo miembro se crea con
OperatorStaff, el subconjunto operativo del rol Operator (ver su box, pausar/reanudar la barra, reponer insumos, catalogo y ventas) y sinUSER_*/ROLE_*, sin billetera ni payouts, sin ventana horaria ni PIN, sin TyC. - El scope lo FUERZA el server: los boxes del operador que llama, nunca los que venga pidiendo el cliente.
- Los permisos efectivos se intersecan con los del propio operador: nadie puede otorgar lo que no tiene, ni siquiera si el rol semilla creciera.
- El operador da de alta, desactiva y reactiva miembros de SU equipo, y nada mas: no cambia
roles, no toca cuentas ajenas, no borra.
PATCHsolo aceptaenabledyname; el email es la identidad de la cuenta y no se edita. - Toda alta/baja queda auditada con el actor.
Por eso OperatorTeamMember no expone rol, permisos ni boxes: si viajaran de ida, el front los
mandaria de vuelta en el PATCH y la invariante duraria hasta el primer refactor. El nombre del rol
(OperatorStaff) no entra al contrato: los nombres de rol del seed nunca vivieron aca
(Role.name es string libre) y agregar uno solo seria una fuente de verdad a medias.
Sin canal de email todavia, el alta fija una contraseña inicial que el miembro debe cambiar al primer ingreso.
Notas de migracion — zerath (F-zerath)
Compila sin cambios (el enum solo suma). Lo que hay que implementar:
- Seed del rol Operator (
src/common/constant/operator-role.constants.ts): sumarBOX_SELLING_WINDOW_UPDATE,BOX_STAFF_PIN_UPDATEyOPERATOR_TEAM_MANAGEa la lista (hoy tieneBOX_READ,BOX_SERVING_TOGGLEy losBOX_INGREDIENT_*). El seed es UPSERT: el redeploy los agrega a los roles ya creados, sin migracion de datos. - Endpoints del box:
PUT /box/:id/selling-windowexigeBOX_SELLING_WINDOW_UPDATEyPUT /box/:id/staff-pinexigeBOX_STAFF_PIN_UPDATE— no elBOX_UPDATEque anticipaba la nota de 3.1.0 (punto 4). El scope del operador se aplica igual que en el resto de/box. - Rol
OperatorStaffen el seed (D55), junto aOPERATOR_ROLE_PERMISSIONSy con el mismo criterio con que se derivo aquel: los permisos REALES de los endpoints que va a llamar la pantalla, no un set "documentado". Punto de partida — ver su box (BOX_READ), pausar/reanudar la barra (BOX_SERVING_TOGGLE), reponer (BOX_INGREDIENT_*,INGREDIENT_SLOT_READ/UPDATE,INGREDIENT_SLOT_CONFIGURATION_READ) y mirar catalogo y ventas (PRODUCT_READ,RECIPE_READ,PURCHASE_READ). FUERA, por decision de D55:USER_*,ROLE_*,WALLET_*,PRICE_*,STATS_READ,BOX_SELLING_WINDOW_UPDATE,BOX_STAFF_PIN_UPDATE,TERMS_MANAGEyOPERATOR_TEAM_MANAGE(un miembro del equipo no se clona a si mismo). Es un rol de seed como los demas: su NOMBRE no viaja por el contrato (Role.nameesstringlibre). - Endpoints del equipo (D55, default-deny + authz-check):
GET/POST/PATCH /api/midas/teamconOPERATOR_TEAM_MANAGE, aplicando las 5 reglas de la invariante de arriba (rol forzado, scope forzado, interseccion de permisos, solo alta/desactivar/reactivar sobre el propio equipo, auditoria con el actor). ElGETdevuelveOperatorTeamMember[]solo del equipo de quien llama; un id ajeno en elPATCHes 404, no 403 (no confirmar que la cuenta existe). - Pin:
"@c80/types": "3.2.0".@c80/domainno cambia (sigue1.1.0).
La UI de midas para el equipo (D55) y el backend van en otra tanda: 3.2.0 solo fija el contrato.
Del lado de D53, las apps ya estan adaptadas (por path): midas abre el modal si tiene cualquiera de los dos permisos
y kyron ofrece la accion "Horario y PIN" de la tabla de Cajas con el mismo criterio; dentro del
modal (@c80/panel) cada seccion se muestra por SU permiso, asi un operador con uno solo no ve un
boton que le va a rebotar con 403. El editor de roles de kyron ya los lista (y de paso
BOX_SERVING_TOGGLE, CONSUMER_REQUEST_MANAGE y AGENT_WORKLOG_MANAGE, que el contrato declaraba
y la pantalla no ofrecia: no habia forma de componer un rol con ellos).
3.1.0 — ventana horaria, pausa desde el kiosco y clausula D26 (D50/D51/D52, 2026-08-29)
MINOR aditivo: no rompe nada de 3.0.0. Agrupa las tres decisiones de la oleada F. Se propaga a
zerath (F-zerath) y vulcan (F-vulcan) con pin exacto 3.1.0 + @c80/domain 1.1.0; las apps del
monorepo ya estan adaptadas (por path).
Nuevo
D50 — ventana horaria de expendio por box (box.ts):
SellingWindow { start: string; end: string }—HH:mm24 h en la hora LOCAL de la box (APP_TIMEZONE, unica para toda la red).start > end= la ventana CRUZA la medianoche (20:00 -> 04:00, el caso normal del canal nocturno).start === endes INVALIDO (sin restriccion se expresa connull).Box.sellingWindow: SellingWindow | null(null= sin restriccion) yUpdateBoxSellingWindowRequest(PUT /box/:id/selling-window).BoxStatus.sellingWindow+BoxStatus.closedBySchedule: boolean— lo calcula ZERATH con la hora del server (unica fuente confiable: el reloj del telefono del cliente no lo es).BoxLiveStatus(evento WSBOX_STATUS) los hereda por suOmit<BoxStatus, 'health'>.PurchaseErrorCode.BOX_CLOSED— rechazo del canje ANTES de debitar, distinto deBOX_PAUSED(decision manual): este cierre es automatico y tiene hora de apertura conocida.- El calculo (cruce de medianoche incluido) vive en
@c80/domain:isWithinSellingWindow,nextOpeningAt,formatSellingWindow,isValidSellingWindow.
D51 — pausa de la barra desde el kiosco con PIN (box.ts):
Box.staffPinSet: boolean(el PIN NUNCA viaja: zerath guarda el hash) ySetBoxStaffPinRequest(PUT /box/:id/staff-pin, 4 a 6 digitos =STAFF_PINde@c80/domain).ServingPauseOrigin = 'OPERATOR' | 'KIOSK' | 'STAFF'yBoxStatus.servingPausedBy(auditoria del origen de la pausa;nullsi la barra no esta pausada). La pausa sigue siendo UNA (Box.servingPaused): esto no agrega un segundo estado.KioskServingPauseRequest/KioskServingPauseResponse(kiosk -> vulcanPOST /api/kiosk/serving-pause-> zerath) yKioskErrorCode(INVALID_PIN,PIN_NOT_SET,TOO_MANY_ATTEMPTS).
D52 — clausula D26 en la app (consumer-request.ts, config.ts, auth.ts):
ConsumerRequestType.CHARGEBACK_APPEAL+CreateChargebackAppealRequest { message }(<= 2000 caracteres,CHARGEBACK_APPEALde@c80/domain; uno abierto por vez).GlobalConfiguration.chargebackPolicySince: IsoDateString | null(no retroactividad: se fija al ENCENDER el enforcement) yChargebackPolicy { enabled, threshold, highAmountArs, since }(respuesta publica deGET /chillbox/policies/chargeback).AccountSuspension { since, reason: 'CHARGEBACKS', appealOpen }yPublicUser.suspension?— solo enGET /chillbox/user/me, con motivo GENERICO. El detalle antifraude (contador, payment_ids) sigue siendo exclusivo deAdminUser(staff de kyron), como en 3.0.0.
Notas de migracion — zerath (F-zerath)
Compila sin cambios (todo lo nuevo es aditivo) salvo los shapes que zerath ancla y que ganaron
campos REQUERIDOS: Box (sellingWindow, staffPinSet), BoxStatus y BoxLiveStatus
(servingPausedBy, sellingWindow, closedBySchedule). Lo que hay que implementar:
- Esquema:
box.selling_window(jsonb o dos columnastime;null= sin restriccion),box.staff_pin_hash(bcrypt, nullable),box.serving_paused_by(enum/varchar, nullable),global_configuration.chargeback_policy_since(timestamptz, nullable) y el tipoCHARGEBACK_APPEALenconsumer_request. - Enforcement
BOX_CLOSED: en el canje, ANTES de debitar (junto aBOX_PAUSED/BOX_OFFLINE), conisWithinSellingWindow(box.sellingWindow, new Date())de@c80/domain; elmessagedeberia citar la apertura (nextOpeningAt). IdemProduct.availabilitysi se quiere apagar la carta. BoxStatus/BoxLiveStatus: completar los tres campos enbox-live-status.service.tsy en el DTO del GET.closedBySchedulese recalcula al emitir (no se cachea) y conviene re-emitir el snapshot al abrir/cerrar la ventana.- Endpoints nuevos (default-deny, authz-check):
PUT /box/:id/selling-window(UpdateBoxSellingWindowRequest, valida conisValidSellingWindow) yPUT /box/:id/staff-pin(SetBoxStaffPinRequest, hash bcrypt, sin lectura) — el permiso que exigen NO esBOX_UPDATE: 3.2.0 (D53) les da uno propio, ver esa seccion;PUT /box/serving-pause(API key del box: valida el PIN, aplicaservingPausedcon la MISMA semantica que el toggle de midas y seteaservingPausedBy: KIOSK, rate-limit 5/10 min por box conKioskErrorCode),POST /chillbox/consumer-request/chargeback-appeal(JWT; 409 si ya hay uno abierto) yGET /chillbox/policies/chargeback(PUBLICO,@Public()). servingPausedBy: el toggle de midas escribeOPERATOR, kyronSTAFF, el kioscoKIOSK; al reanudar vuelve anull.suspensionen/me:toPublicUserno cambia; el controller de/chillbox/user/meagregasuspension(fecha +CHARGEBACKS+ si hay descargo abierto) solo para el titular.- No retroactividad (D52c): al pasar
chargebackSuspensionEnabledde false a true, fijarchargebackPolicySince = now()y neutralizar los eventos previos (forgiven = count, evidencia intacta); el gatillo solo cuenta contracargos posteriores. - Pins:
"@c80/types": "3.1.0","@c80/domain": "1.1.0".
Notas de migracion — vulcan (F-vulcan)
Aditivo y FUERA del nucleo (core:check debe seguir verde):
- Proxy
POST /api/kiosk/serving-pauseen el listener local del kiosco (hoy solo GET/configy/state): recibeKioskServingPauseRequest, lo reenvia a zerath (PUT /api/box/serving-pause) con la API key del box y devuelveKioskServingPauseResponseo el error con suKioskErrorCode. Sin PIN en logs. GET /statesuma (aditivo, la app kiosk ya tolera su ausencia):servingPaused: boolean,servingPausedBy: ServingPauseOrigin | nullyclosedBySchedule: boolean(+ opcionalsellingWindow), tomados del ultimoBOX_STATUSrecibido por WS. Sin ellos el kiosco no muestra "pausada"/"cerrado por horario", pero el gesto y el PIN funcionan igual.- Pins:
"@c80/types": "3.1.0","@c80/domain": "1.1.0".
3.0.0 — contrato "de precision" (D44, 2026-08-29)
Un solo major que agrupa todo el cambio de contrato de la reestructuracion pre-produccion. Se propaga
a zerath (F2) y vulcan (F3) en la misma tanda que los errores tipados y las rutas canonicas de zerath
(D47); las apps del monorepo ya estan adaptadas. Las rutas citadas en los docs son las CANONICAS de
D47d (/api/user, /api/recipe, /api/ingredient-slot, ...; F2 las aplica).
BREAKING (2.x -> 3.0.0)
- Fechas:
IsoDateStringunico. Se eliminanTimestamp(string | Date) yNullableTimestamp, y los 27 camposDatepuros + 3Date | stringpasan aIsoDateString(=string, ISO 8601 UTC). Afecta a:Role,Box,PriceConfiguration,GlobalPoint,MotorConfiguration,IngredientSlotConfiguration,Driver,GlobalConfiguration,FeatureFlag.updatedAt,BoxIngredient,TaskStats(6 campos),Movement(inProgressAt/completedAt/failedAt/cancelledAt),ChatMessage.date,BoxHealthPayload.timestampy a todo lo que eraTimestamp(createdAt/updatedAt/servedAt/...de purchase, recharge, invoice, wallet, terms, worklog, consumer-request, user, box-session).NullableTimestamp(string | Date | null | undefined) queda comoIsoDateString | nullen campos ya opcionales. - Fuera los shapes de ENTITY que ningun consumidor usaba y exponian internals (tabla de uso
2026-08-29: 0 imports en zerath, vulcan y las 4 apps):
User(conpassword?y relaciones),Purchase,Recharge,Favorite,Rating. Derivados:Product.ratings?fuera (zerath nunca lo envio; quedaaverageRating/userRating);Task.recipe?yTask.purchase?fuera (no viajan);Task.product?: FK | null;Box.owner?: FK | null(eraUser | FK | null);Box.apiKey?fuera (la key completa solo viaja en la respuesta de regenerarla);OperatorProfile.useres{ id, name, email }. Recipe/Partdicen la verdad (lo que zerath sirve enGET /recipe,/recipe/product/:idyProduct.recipe):Recipe.parts: Part[]requerido,Recipe.product?: RecipeProductRef;Part.unitMl: numberrequerido,Part.boxIngredient: RecipeIngredientRef, sinPart.recipe?.Product.recipe?: Recipe | null(ya noFK | Recipe | null).Product.image?: string | null(el seed manda null). Los paneles borraron suReciperedeclarada.- Opcionales que ya eran obligatorios:
IngredientSlotConfiguration.cupCapacityMl: numberyBoxStatus.servingPaused: boolean(zerath los envia siempre desde 2.x). ChatMessagesin index signature ([key: string]: unknown): un campo que un lado necesite transportar se declara.date?: IsoDateString(el emisor usanew Date().toISOString()).QueryParamsyPaginationParamsse mudan a@c80/core(tipos de front: 0 usos en zerath/vulcan).PaginationParamspierdeboxId(nadie lo leia por ese tipo).Permissionpierde 15 miembros fantasma (ningun endpoint los exigia ni ningun rol los otorgaba):INGREDIENT_SLOT_CONFIGURATION_TOGGLE,FAVORITE_READ/WRITE/DELETE,RATING_WRITE,PART_READ/WRITE/UPDATE/DELETE,RECHARGE_WRITE/UPDATE/DELETE/APPROVE/REJECT,PURCHASE_DELETE. Una filarole.permissionscon esos strings en una DB vieja es inofensiva (nunca gatearon nada).CreatePurchaseResponse.codefuera: nunca viajo en un 200. Los codigos de rechazo viajan enApiErrorResponse.codedel HTTP 400 (asi lo hace zerath desde 2.7.0 conINSUFFICIENT_STOCK).
Nuevo (no rompe)
IsoDateString,ApiErrorResponse({ statusCode, timestamp, path, method, error, message, code? }, el body del filtro global de zerath).- Lo que cruza proceso y vivia como tipo local:
GoogleLoginRequest(chillbox),BoxClientResponse(chillbox),CreateRatingRequest(chillbox),CreateRecipeRequest/UpdateRecipeRequest(paneles),DriverUpsertRequest(vulcan),IngredientSlotWithSensor(vulcan),IngredientSlotCalibrationRequest(vulcan),LevelSensorReadingUpdate+LevelSensorReadingsBatchRequest(vulcan),RecipeProductRef/RecipeIngredientRef. PurchaseErrorCodesumaBOX_PAUSED,BOX_OFFLINE,QUEUE_FULL,INSUFFICIENT_POINTS(hoy zerath los rechaza con 400 SIN code; F2 los emite). Candidatos para un minor futuro, si el front los quiere ramificar: limite de alcohol (D.3), producto sin puntos, receta no servible.CreatePurchaseDto.idempotencyKey?: string(uuid v4 por toque de "Canjear"; F2 lo implementa).LoginResponseyBoxRoomSubscribeResponsese quedan: zerath los implementa en F2.
Notas de migracion — zerath (F2)
Compilacion de zerath 2d1945c contra el .d.ts de 3.0.0 (sin tocar el repo): 42 errores, TODOS
de fechas (Date no asignable a IsoDateString); cero errores por los tipos podados, los permisos,
ChatMessage, Task, Recipe o Product (zerath no anclaba esos shapes: por eso el punto B).
A. Fechas (obligatorio para compilar). El runtime NO cambia (Nest ya serializaba Date a ISO);
solo el tipo pasa a decir la verdad. Regla: la capa que arma la respuesta hace toISOString()
(helper sugerido toIso(d: Date | null | undefined): IsoDateString | null), y los DTOs de respuesta
declaran IsoDateString. Sitios (archivo:linea de 2d1945c):
- DTOs de respuesta que
extends/implementsel contrato conDate:billing/dto/invoice-response.dto.ts(8, 32:emittedAt,lastAttemptAt,createdAt,updatedAt),box-session/dto/box-session-response.dto.ts(10-11:expiresAt,serverNow),favorite/dto/favorite-response.dto.ts(29),purchase/dto/admin-purchase-response.dto.ts(9),purchase/dto/box-status-response.dto.ts(9:lastHealthAt),purchase/dto/purchase-history.dto.ts(8),purchase/dto/purchase-status-response.dto.ts(10),recharge/dto/admin-recharge-response.dto.ts(9),wallet/dto/wallet-response.dto.ts(11-12, 27). - Servicios que mapean entidades al contrato:
agent-worklog.service.ts(208-210),billing.service.ts(559),box.service.ts(190, 204, 214, 225:priceConfiguration/motorConfiguration/ingredientSlotConfiguration/driverscon fechas Date),consumer-request.service.ts(124-125),movement.service.ts(534-535, 607, 669-671),box-live-status.service.ts(131:lastHealthAt),recharge.service.ts(1619-1620),task.service.ts(342-343, 703),terms.service.ts(66, 260-261),user/utils/user.helper.ts(65:Rolecon fechas dentro dePublicUser; 83:suspendedAt),websocket.gateway.ts(403-404).
B. Anclar lo que entro al contrato (regla "prohibido el DTO gemelo sin ancla").
auth/dto/google-login.dto.ts:GoogleLoginDto implements GoogleLoginRequest.rating/dto/create-rating.dto.ts:CreateRatingDto implements CreateRatingRequest.box/dto/box-response.dto.ts:BoxClientResponseDto->satisfies/implements BoxClientResponse.driver/dto/upsert-driver.dto.ts:UpsertDriverDto implements DriverUpsertRequest.ingredient-slot/dto/update-sensor-calibration.dto.ts:implements IngredientSlotCalibrationRequest;batch-sensor-reading.dto.ts:SensorReadingItemDto implements LevelSensorReadingUpdateyBatchSensorReadingDto implements LevelSensorReadingsBatchRequest;findByBoxWithSensors(): Promise<IngredientSlotWithSensor[]>.recipe/dto/create-recipe.dto.ts/update-recipe.dto.ts:implements CreateRecipeRequest/UpdateRecipeRequest;recipe-response.dto.ts:RecipeResponseDto/PartResponseDtoson gemelos sin ancla deRecipe/Part(mismos campos, fechas Date): pasar asatisfiescon fechas ISO y borrarProductFK/BoxIngredientFKlocales (sonRecipeProductRef/RecipeIngredientRef).common/filters/global-exception.filter.ts: elerrorResponsesatisfies ApiErrorResponse.LoginResponse(auth) yBoxRoomSubscribeResponse(gateway): implementarlos.websocket/dto/chat-message.dto.ts:ChatMessageDtopuede conservar su index signature propia (una clase puede tener mas que la interfaz), pero el contrato ya no la exige: es el momento de encender la whitelist del relay.
C. Errores tipados y canje. purchase.service.ts: assertBoxOperational -> BOX_PAUSED /
BOX_OFFLINE, resolveQueueSlot -> QUEUE_FULL, saldo -> INSUFFICIENT_POINTS (como ya hace
INSUFFICIENT_STOCK: 400 con { code, message }); CreatePurchaseDto.idempotencyKey (cache por
usuario+clave; un reintento devuelve la compra ya creada). chillbox YA lee code del body de error.
D. Adoptar @c80/domain (pin exacto 1.0.0). common/utils/money.util.ts -> re-export o
reemplazo por round2/roundArs/roundTo/ceil2; point-calculation.util.ts -> calculatePoints;
cup-capacity.util.ts -> calculateRecipeVolume + recipeFitsCup (el mensaje del 400 se arma con el
desglose); level-sensor.util.ts -> calculateCurrentMl; datetime.constants.ts -> APP_TIMEZONE;
error.util.ts -> getErrorMessage; websocket.gateway.ts:672 (validModes literal) -> isSystemMode.
Semantica identica (verificada en runtime contra los valores de zerath).
E. Pins: "@c80/types": "3.0.0", "@c80/domain": "1.0.0" (exactos, D46).
Notas de migracion — vulcan (F3)
Compilacion de vulcan HEAD contra el .d.ts de 3.0.0: 2 errores, ninguno en el nucleo.
src/shared/types/index.ts:6(TS2308): el barrel re-exportaLevelSensorReadingUpdatedesdecore.types(contrato) y desdelevel-sensor.types.ts(local, lineas 76-90). Borrar el local (mismo shape); idemIngredientSlotSensor->IngredientSlotWithSensordel contrato.src/modules/connection/websocket.service.ts:295:date: new Date()->new Date().toISOString()(ChatMessage.dateesIsoDateString).BoxHealthPayload.timestampya viaja ISO (box-health-heartbeat.service.ts:87): sin cambio.src/modules/api/driver.service.ts:11:DriverUpsertRequestlocal -> el del contrato (y el re-export demodules/api/index.ts).ingredient-slot.service.ts: tipar los bodies conIngredientSlotCalibrationRequestyLevelSensorReadingsBatchRequest.shared/utils/logging.utils.tsgetErrorMessage->@c80/domain(cambio de semantica deliberado: un objeto SINmessagecae al fallback en lugar deJSON.stringify; conmessagesuma elcause).SYSTEM_MODES/isSystemModesi vulcan valida modos por el cable.ChatMessagesin index signature: vulcan compila sin cambios (verificado). Los permisos podados y los tipos de entidad eliminados no lo tocan.- Pins exactos:
"@c80/types": "3.0.0","@c80/domain": "1.0.0". Nada de esto entra al nucleo (core:checkdebe seguir verde).
Que NO vive aca
Tipos propios de un solo repo (UI de un front, Dispenser* de vulcan, estado de formularios como
RecipePart de chillbox o PartForm de panel) quedan locales en cada repo.
Uso
import { Box, Movement, RoomEvent } from '@c80/types';Build dual CJS/ESM
scripts/build-dual-lib.js types compila el mismo fuente dos veces:
dist/libs/types/esm/— ESM (con stub{"type":"module"}); condicionimport.dist/libs/types/cjs/— CommonJS (el package.json raiz no declaratype); condicionrequireymain(consumidores NestJS).
El package.json de esta carpeta es el PUBLICABLE (se copia tal cual al dist).
Regla: los imports internos del fuente llevan extension .js (sin ella la
resolucion ESM de Node revienta en runtime aunque el gate compile).
Build / release (desde la raiz del workspace)
pnpm build:types # arma dist/libs/types (esm + cjs)
npx nx release --projects=types --dry-run --skip-publish # previsualizar (no toca nada)
pnpm release:types # gate + version + CHANGELOG + tag [email protected] + publishEl bump lo deciden los conventional commits desde el ultimo tag [email protected] (feat = minor,
fix = patch, BREAKING CHANGE/! = major). Detalle del flujo en el README raiz.
Antes de publicar, validar el runtime del dist: require('@c80/types') e
import('@c80/types') deben cargar y exponer los enums (el gate NO lo detecta;
probar con el dist montado en un node_modules suelto).
