---
module: msp.contact-groups
title: Contact Groups
tab: Architect
order: 30
audience: dev
---

How the **Contact Groups** feature is built and wired. See also [platform.data-architecture], [platform.kafka], [platform.service-ownership].

## Services involved

| Concern | Service | Notes |
|---------|---------|-------|
| UI | `hiveops-msp` (Svelte SPA, port 5186) | `ContactGroupsView.svelte`; no backend of its own |
| Group data + notify | **`hiveops-messaging`** (port 8086, DB `hiveiq_messaging`) | Owns all group tables and the notify action |
| User directory | `hiveops-auth` | Member picker calls `GET /auth/api/msp/users?size=500`; members stored as auth user IDs |
| Delivery surface | `hiveops-messaging` `messages` inbox | Electron app polls unread count |

Ownership is clean: **messaging owns groups + subscriptions + delivery**; auth owns the user directory. Members are foreign user IDs — messaging does not store user names/emails (resolved client-side).

## Request path

Browser → nginx (`/messaging/*`, prepends `/api`) → `hiveops-messaging` `ContactGroupController` at base `/api/messaging/groups`. JWT is validated by `JwtAuthenticationFilter` (shared `hiveops-security-common` `JwtTokenValidator`), producing a `MessagingPrincipal(userId, email, role, institutionKey)` from the `sub`/`email`/`role`/`institutionKey` claims. Spring CORS is disabled — nginx is the CORS authority.

## Data model (PostgreSQL `hiveiq_messaging`)

- **`contact_groups`** (Flyway `V4`) — `id`, `uuid` (business key, exposed to API), `name` UNIQUE, `description`, `created_by_user_id`, `created_at`, `updated_at`.
- **`contact_group_members`** (V4) — PK `(group_id, user_id)`, `added_at`; `group_id` FK `ON DELETE CASCADE`. Index `idx_cgm_group_id`.
- **`group_subscriptions`** (Flyway `V5`) — `id`, `group_id` FK `ON DELETE CASCADE`, `event_type` VARCHAR(100), UNIQUE `(group_id, event_type)`. Index `idx_group_sub_event`.
- **`messages`** — reused as the delivery store; group notifications land here as `type = ACTION`.

Deleting a group cascades to members and subscriptions. The API always addresses groups by `uuid`, never the numeric `id`.

## Read flow

- `getAllGroups()` — `findAll()` over `contact_groups`, mapped to `ContactGroupResponse` (adds `memberCount` via `countByGroupId` and `subscribedEventTypes` via `findEventTypesByGroupId`). **No institution filter.**
- `getGroupDetail(uuid)` — resolves by uuid, then loads `memberUserIds` + `eventTypes` into `ContactGroupDetailResponse`.

## Write flow

All mutations go through `ContactGroupService`, each calling `requireAdmin(principal)` first. Subscriptions are validated against `NotificationEventType.ALL` (`SYSTEM_UPDATE`, `INCIDENT_SUMMARY`, `REPORT_SYSTEM_CHANGE`, `SYSTEM_ANNOUNCEMENT`) and stored uppercased.

## Notify flow (the fan-out)

`POST /api/messaging/groups/notify` → controller re-checks `principal.isAdmin()` → `GroupNotificationService.notifyByEventType()`:

1. `groupSubscriptionRepository.findByEventType(eventType)` → subscribed groups.
2. For each group, `contactGroupMemberRepository.findUserIdsByGroupId()` → member IDs.
3. For each member, **directly persist** a `Message` (`type = ACTION`, `senderEmail = "system"`, `recipientUserId = member`, subject/body/metadata from the request). One save per recipient; a failure for one member is logged and does not abort the rest.
4. Returns `{ eventType, dispatched }` where `dispatched` is the total messages written.

> **Important divergence from the platform executor→Kafka→record-store pattern:** this notify path does **not** publish to Kafka. It writes `messages` rows synchronously via `messageRepository.save()`, bypassing `MessageProducer`. Contrast `MessageService.send()`, which persists then publishes to `hiveops.messages`. Groups therefore participate in the messaging domain's data flow (they are the fan-out target) but the operator-triggered notify is DB-direct.

## Where Kafka does touch groups

Groups are also a **fan-out target for inbound Kafka events**. `MessageService.persistFromEvent()` (fed by `MessageConsumer` on topic `hiveops.messages`) checks `event.getRecipientGroupId()`; if set, it resolves the group by uuid and writes one `messages` row per member — the same fan-out shape as notify, but driven by an external producer rather than the MSP admin action. Topics defined in `KafkaConfig`: `hiveops.messages`, `hiveops.reports.completed`.

## Key endpoints (all under `/api/messaging/groups`)

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/` | List groups |
| POST | `/` | Create (admin) |
| GET | `/{groupId}` | Detail (members + subscriptions) |
| PUT | `/{groupId}` | Rename / edit (admin) |
| DELETE | `/{groupId}` | Delete + cascade (admin) |
| POST | `/{groupId}/members` | Add members (admin) |
| DELETE | `/{groupId}/members/{userId}` | Remove member (admin) |
| GET | `/{groupId}/subscriptions` | List event types |
| POST | `/{groupId}/subscriptions` | Subscribe (admin) |
| DELETE | `/{groupId}/subscriptions/{eventType}` | Unsubscribe (admin) |
| POST | `/notify` | Fan out ACTION messages (admin) |

`{groupId}` is the group **uuid**.
