
Real-Time Corporate Chat & Announcements
Real-time corporate messaging with group chats, polls, broadcast announcements and push notifications, embedded into the company's ERP.
Technical documentation
Executive Summary
Real-time corporate messaging platform, built as a native module of Grupo Garza Limón's ERP (not a bolted-on generic chat, but integrated into the system's existing visual identity and pages).
Used company-wide: 300–500 concurrent users. It replaced sending internal documents through uncontrolled channels (WhatsApp, email, etc.) by centralizing file sharing inside the corporate chat with automatic backup to AWS S3 — eliminating the risk of losing internal documents.
Covers the full internal communication cycle of a company: 1:1 and group conversations with media attachments, WhatsApp-style polls, and mass announcements with per-recipient read confirmation — all with real-time delivery, resilience against disconnections, and guaranteed push notifications even when the app is closed.
My Role
Fullstack, within a team of 3: myself + a mobile developer + a UI/UX designer.
Direct ownership of:
- Full backend (NestJS, WebSockets, database, JWT-based ERP integration, push queue, S3).
- Web frontend (vanilla JS chat app).
- Embeddable React widget for injection into legacy ERP pages.
I also acted as technical lead for collaboration with the mobile developer (API and socket event consumption from the mobile app) and the UI/UX designer (translating mockups into actual implementation, both in the web chat and the widget).
Backend architecture decisions, the data model, and the trade-offs in section 6 were mine. Visual and UX design were the designer's responsibility.
Key Features
- Real-time 1:1 and group chat
- File sharing: images, audio, video, documents, and presentations (PPTX)
- Emoji reactions on any message
- Replies quoting a specific message
- Read receipts ("seen") and "typing…" indicator
- WhatsApp-style polls, single or multiple choice
- Mass announcements with per-recipient read confirmation
- Push notifications to mobile devices, with automatic retries
- Widget embeddable directly into existing ERP pages, with no changes to their code
Challenges and How I Solved Them
Reconnections and heavy history reloads
Situation: users disconnect constantly (corporate network, network switching, app closing), and reloading the full history on every reconnect is slow and unnecessary.
Action: implemented cursor-based delta sync — the client sends the last known message id per chat and the server returns only what's new.
Result: fast reconnection without reloading full histories, with consistent state between client and server.
Push delivery without duplicates or dead tokens
Situation: push notification delivery needed to be reliable, without sending the same notification twice or accumulating tokens from uninstalled devices.
Action: designed a persistent queue (queue_mensajes) with a unique key on device+message, processed by a cron job every 5 seconds using pessimistic locking to prevent double sends; added a separate cron job that checks Expo receipts every minute and purges tokens flagged as DeviceNotRegistered.
Result: guaranteed push delivery with no duplicates and no buildup of invalid tokens.
Employee offboarding without breaking conversation history
Situation: when an employee leaves the company, their personal data shouldn't stay exposed, but conversations they participated in still need to be readable for everyone else.
Action: implemented soft-delete combined with an @AfterLoad hook that masks the offboarded user's name, email, and profile image at read time.
Result: chat history stays intact and coherent, without exposing former employees' data.
On-premise deployment without containers, with a legacy frontend requiring no build step
Situation: the project had to run on existing infrastructure without Docker, and the frontend had to be injectable into legacy ERP pages without adding a build step to the inherited pipeline.
Action: structured the backend to deploy via nest build && node dist/main under PM2, and built the frontend bundles (vanilla JS and the React widget) as IIFEs that are edited and served directly, with no compilation step.
Result: frictionless integration with the team's internal deployment processes, at the cost of giving up modern froten tooling (a conscious trade-off, see section 6).
Architecture
Modular NestJS backend, organized by domain: auth, chat, comunicados (announcements), presence, gateway (WebSocket), aws, and push.
HTTP request flow: request → AuthGuard (validates the JWT from the header) → controller → service → TypeORM repository, with a @User() decorator that injects the JWT payload wherever needed. To keep controllers clean, composite decorators were built (GetEndpoint, PostEndpoint, PatchEndpoint, DeleteEndpoint) that combine route, Swagger docs, and auth into a single annotation.
Real-time layer: Socket.IO with a room-based scheme — each user joins user:<erpId> and a chat:<chatId> room for every conversation they belong to. When a chat's participants change, GatewayService moves sockets between rooms. Outgoing events: mensaje:nuevo, mensaje:leido, chat:escribiendo, usuario:estatus, encuesta:voto, comunicado:reaccion. Incoming events: chat:typing, presence:status, chat:marcar_leido, chat:reaccionar. All over the same connection, authenticated via JWT at handshake.
Authorization: a permissions guard reads ERP permission codes embedded in the JWT (565 delete chats, 566 delete messages, 567 create announcements, 568 delete announcements). The backend doesn't manage users or passwords — it acts as a resource server that trusts the ERP as the issuer (de facto SSO), which greatly simplified the security model.
Database: versioned migrations with TypeORM on PostgreSQL. Users have a dual identity: the ERP identifies each employee with an erpId (string) stored as a unique foreign key, while an internal auto-incrementing numeric PK is used for TypeORM joins. Users are created lazily — the record is born on the first connection with a valid JWT, with no prior sync required from the ERP. All relevant entities use soft-delete.
Technical Decisions and Trade-offs
Polls modeled as messages, not as a parallel entity. By treating a poll as TipoMensaje.ENCUESTA (message type: POLL), it inherits persistence, chronological ordering, cursor-based pagination, socket delivery, read receipts, quoting, soft-delete, and delta sync with no extra code. Poll-specific data lives in child tables and gets serialized inside the message. Voting has set semantics — the client sends the full selection and the server reconciles it in a transaction — which covers both single-choice and multiple-choice voting with the same logic.
Announcements modeled separately, deliberately. Their delivery semantics are different (one-shot, with per-recipient read status, not belonging to any chat), so they weren't forced into the message abstraction. This same asymmetry shows up in push delivery: messages and polls go through the persistent queue_mensajes queue (unique key on device+message), while reactions and announcements are sent directly via the Expo SDK, since they're ephemeral/one-shot and would conflict with that constraint.
No-build-step frontend, by project constraint, not by preference. Two clients (a full vanilla-JS chat app and a React widget embeddable via CDN) share a state layer (chat-store.js). Both ship as IIFE bundles that are edited and served directly. This prioritizes deployment simplicity for a legacy frontend over modern development tooling — a conscious trade-off forced by context, not an ideal in the abstract.
Infrastructure
On-premise deployment on the company's existing infrastructure: Node.js managed by PM2 with autorestart and a 1 GB memory limit. Since the system integrates directly into the ERP ecosystem (same server, private network), no containerization or formal CI/CD pipeline was required — the deployment flow is nest build → node dist/main, deliberately kept simple to fit the team's internal processes.
Media stored in AWS S3. Push notifications delivered via Expo. Swagger disabled in production. CORS