
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
Overview
The system needed to cover direct and group messaging with media support, reactions, read receipts, and real-time presence, along with WhatsApp-style polls and mass announcements with per-recipient read confirmation. All of this with low latency, disconnection resilience, and guaranteed push delivery via a persistent queue. The project constraints were equally relevant: on-premise deployment on the company's existing infrastructure without containers, and a frontend that had to be injectable into legacy ERP pages without requiring a build step.
Requirements
Functional: direct and group chat; media sharing; reactions, replies and read receipts; real-time presence (online, idle, offline, on-vacation); single- or multiple-choice polls; broadcast announcements (to everyone or targeted) with reactions and per-recipient read confirmation; push notifications to offline users; ERP-driven user activation/deactivation/reactivation; reconnection sync.
Non-functional: low-latency delivery (WebSocket), resilience to disconnects (cursor-based delta sync), guaranteed push delivery (persistent queue with retries and receipt verification), and authentication without managing its own credentials (the ERP issues the JWT). Constraints: on-premise deployment on the company's existing infrastructure (PM2, no containers), and a frontend that can be injected into legacy ERP pages with no build step.
Architecture
A modular NestJS backend organized by domain: auth, chat, comunicados (announcements), presence, gateway (WebSocket), aws and push. The HTTP request lifecycle is: request → AuthGuard (verifies the JWT from the header) → controller → service → TypeORM repository, with a @User() decorator that injects the JWT payload anywhere in the pipeline. To keep controllers clean, composite decorators (GetEndpoint, PostEndpoint, PatchEndpoint, DeleteEndpoint) bundle route, Swagger docs and auth into a single annotation.
The real-time layer uses Socket.IO with a room scheme: each user joins user:<idErp> and a chat:<chatId> room per conversation they belong to. When a chat's participants change, the GatewayService moves sockets between rooms. Outbound events (mensaje:nuevo, mensaje:leido, chat:escribiendo, usuario:estatus, encuesta:voto, comunicado:reaccion) and inbound ones (chat:typing, presence:status, chat:marcar_leido, chat:reaccionar) all ride a single connection authenticated by JWT in the handshake.
Fine-grained authorization is handled by a permissions guard that reads ERP permission codes embedded in the JWT (565 delete chats, 566 delete messages, 567 create announcements, 568 delete announcements). The backend manages no users or passwords: it acts as a resource server trusting the ERP as the token issuer (de-facto SSO), which greatly simplified the security model.
Database
The data model uses versioned TypeORM migrations against PostgreSQL. One key design decision was giving users a dual identity: the ERP identifies each employee with an idErp string stored as a unique external key, while internally an auto-incremented numeric PK is used for TypeORM joins. Users are created lazily on first valid JWT connection, with no prior sync required from the ERP. When an employee is deactivated, a soft-delete combined with an @AfterLoad hook masks their name, email, and avatar — keeping conversation history coherent without exposing former employee data. All relevant entities use soft-delete throughout.
Technical decisions and trade-offs
The most interesting design decision was to model a poll as a message (TipoMensaje.ENCUESTA) rather than as a parallel entity. As a result, polls inherit, with no extra code, persistence, chronological ordering, cursor pagination, socket delivery (mensaje:nuevo), read receipts, quoting, soft-delete and delta sync. Poll-specific data lives in child tables and is serialized into the message. Voting uses set semantics (the client sends the full desired selection and the server reconciles it in a transaction), covering both vote-changing (single-choice) and toggling (multiple-choice) with one path.
By contrast, announcements are NOT messages: their delivery semantics differ (one-shot, per-recipient read status, not tied to a chat), so they were modeled separately. This asymmetry (reusing the message abstraction where it fits and splitting it where it doesn't) also shows up in push: messages and polls go through the persistent queue_mensajes queue (whose unique key is device+message), while reactions and announcements are sent directly via the Expo SDK, since they're ephemeral/one-shot and would collide with that constraint.
The frontend consists of two clients sharing a shared state layer (chat-store.js): the full chat app in vanilla JavaScript, and a React widget designed to be injected into legacy ERP pages by loading React and Socket.IO from CDN. Both bundles are IIFEs with no build step — edited and deployed by simply serving the file — a deliberate trade-off that prioritizes deployment simplicity over modern dev tooling, driven by the need to coexist with a legacy frontend without altering its pipeline.
Development, challenges and future work
The core challenges were resilience and consistency. To survive reconnections, a cursor-based delta sync was implemented: the client sends the last known message id per chat and gets back only what's new, avoiding full history reloads. Push delivery was solved with a cron every 5 seconds that processes the queue under a pessimistic lock to prevent double sends, a per-minute cron that verifies Expo receipts, and automatic cleanup of dead tokens (DeviceNotRegistered).
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), containerization and a formal CI/CD pipeline were not required; the deployment flow is nest build → node dist/main, kept deliberately simple to align with the team's internal processes. Media is stored on AWS S3, push notifications are delivered via Expo, Swagger is disabled in production, and CORS is restricted to ERP origins.