Back to projects
Real-Time Corporate Chat & Announcements project cover

Real-Time Corporate Chat & Announcements

CompletedReal-time backend (REST + WebSocket API) with an embeddable chat widgetApr–May 2026Grupo Garza Limón

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

Gallery