---
module: technical.messaging
title: Messaging — user messages, broadcasts & system alerts
tab: Overview
order: 10
audience: dev
---

> **DRAFT · internal.** Written 2026-07-01 from hiveops-messaging source. Verify against code before relying on specifics.

## Responsibility

`hiveops-messaging` owns in-app user messaging for the platform: direct messages between users, admin BROADCAST messages, and ACTION alerts injected by other services (e.g. incident) over Kafka. It also owns per-user read/unread state, per-user soft-delete, and MSP-managed **contact groups** that subscribe to event types for fan-out notifications. Message content and delivery state live here — no other service writes it. Cross-link [platform.service-ownership]. Runs on port **8086** (`PORT` env override).

## Data

- **Database:** `hiveiq_messaging` (PostgreSQL, prod profile). Dev profile uses H2 in-memory (`create-drop`). Connection via split `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USERNAME` / `DB_PASSWORD` env vars (not `DB_URL`). Flyway migrations in `db/migration` (prod only). Cross-link [platform.data-stores].
- **Key tables** (from V1–V5 migrations):

| Table | Purpose |
|-------|---------|
| `messages` | Core record. `type` = DIRECT \| BROADCAST \| ACTION; `sender_user_id`/`sender_email`; `recipient_user_id` (NULL for BROADCAST); `subject`, `body`, `metadata` (JSONB); `institution_key` (V3, for institution-scoped broadcasts); `uuid`, `created_at` |
| `message_reads` | Per-user read state; UNIQUE (message_id, user_id) — drives BROADCAST read tracking |
| `message_deletes` | Per-user soft-delete; PK (message_id, user_id) |
| `contact_groups` | MSP-managed groups (V4); `name` UNIQUE |
| `contact_group_members` | Group membership; PK (group_id, user_id) |
| `group_subscriptions` | Event types a group is subscribed to (V5); UNIQUE (group_id, event_type) |

## Kafka

Cross-link [platform.kafka]. Bootstrap via `KAFKA_BOOTSTRAP_SERVERS` (default `localhost:9092`). Topic constants in `config/KafkaConfig.java`.

**Produced:**

| Topic | Producer | Notes |
|-------|----------|-------|
| `hiveops.messages` | `kafka/MessageProducer.publishMessage` | Fired after every REST-initiated send (`MessageService.sendMessage`). Key = sender userId or `"system"`. Payload = `MessageEvent` (carries `sourceService`) |

**Consumed:**

| Topic | Listener (group) | Behavior |
|-------|------------------|----------|
| `hiveops.messages` | `kafka/MessageConsumer.onMessageEvent` (group `hiveops-messaging`) | Persists events from external services; **skips events where `sourceService == "hiveops-messaging"`** to avoid re-persisting its own REST sends |
| `hiveops.reports.completed` | `kafka/ReportCompletedConsumer.onReportCompleted` (group `hiveops-messaging-reports`) | Deserializes `ReportCompletedEvent` as raw String via a separate `reportKafkaListenerContainerFactory`; creates one DIRECT message per `recipientUserIds` entry |

## API surface

Two controllers. All endpoints require a JWT Bearer token (parsed by `JwtAuthenticationFilter` into `MessagingPrincipal`).

**`/api/messaging/messages`** (`MessageController`):

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/unread/count` | Unread count — polled by Electron app every 30s |
| GET | `/unread` | All unread messages |
| GET | `/` | Paged inbox (Spring `Pageable`) |
| GET | `/sent` | Messages sent by current user |
| POST | `/` | Send a message (BROADCAST requires admin) |
| PATCH | `/{messageId}/read` | Mark one message read (204) |
| PATCH | `/read-all` | Mark all read (204) |
| DELETE | `/{messageId}` | Per-user soft-delete (204) |

**`/api/messaging/groups`** (`ContactGroupController`):

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/` | List contact groups |
| POST | `/` | Create group (admin) |
| GET | `/{groupId}` | Group detail (members + subscriptions) |
| PUT / DELETE | `/{groupId}` | Update / delete group (admin) |
| POST | `/{groupId}/members` | Add members (admin) |
| DELETE | `/{groupId}/members/{userId}` | Remove member (admin) |
| GET/POST | `/{groupId}/subscriptions` | List / add event-type subscription |
| DELETE | `/{groupId}/subscriptions/{eventType}` | Remove subscription (admin) |
| POST | `/notify` | Fan out to all groups subscribed to an event type (admin) |

## Gotchas

- **Base path is `/api/messaging/...`, not `/api/v1/messages`.** The repo's `CLAUDE.md` still documents the old `/api/v1/messages` paths — the code (`@RequestMapping`) is the source of truth.
- **Admin check is role-string based, not `@PreAuthorize`.** `MessagingPrincipal.isAdmin()` returns true for role `ADMIN`, `MSP_ADMIN`, or `BCOS_ADMIN` (case-insensitive). BROADCAST send and group mutations throw `AccessDeniedException` when not admin — enforced in controller code, not annotations.
- **Self-consume guard.** REST sends both persist locally *and* publish to `hiveops.messages`; `MessageConsumer` drops any event whose `sourceService` is `hiveops-messaging`, so the service does not double-persist its own messages.
- **Two consumer factories.** `hiveops.messages` uses the default `JsonDeserializer` bound to `MessageEvent` (`spring.json.value.default.type`); `hiveops.reports.completed` needs a dedicated `StringDeserializer` factory (`reportKafkaListenerContainerFactory`) and manual Jackson parsing to avoid a type conflict with that default.
- **`JWT_SECRET` has no default** — the app fails fast if unset (shared secret with hiveops-auth/mgmt).
- **No dead-letter topic.** `MessageConsumer` logs and swallows persistence failures (a TODO note calls for a `KafkaListenerErrorHandler` DLT in production).
- **Institution scoping.** Unread/inbox queries take `principal.institutionKey()`; `messages.institution_key` (V3) supports institution-scoped broadcasts.
