Skip to content

feat/fix: Real-Time Encrypted Chat Engine, Redis Streams Event Bus, and Chatbot Backend#149

Merged
ilramdhan merged 17 commits into
mutugading:mainfrom
ilramdhan:fix/master-batch-costing
Jul 16, 2026
Merged

feat/fix: Real-Time Encrypted Chat Engine, Redis Streams Event Bus, and Chatbot Backend#149
ilramdhan merged 17 commits into
mutugading:mainfrom
ilramdhan:fix/master-batch-costing

Conversation

@ilramdhan

Copy link
Copy Markdown
Member

Description

This massive PR introduces the complete backend architecture for a highly scalable, real-time Chat & AI Chatbot System within the IAM Service.

It features end-to-end AES-256-GCM encryption for messages, file/image attachments via MinIO, and a production-grade event bus powered by Redis Streams (replacing standard Pub/Sub) using zero-copy Protobuf binary serialization. Additionally, it implements intelligent offline email notifications, read receipts, message edit history, and per-user conversation clear-history logic. A critical timeout extension for long-running Finance batch RPCs is also included.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that changes existing API)
  • ♻️ Refactor (code change without new feature or bug fix)
  • 📚 Documentation update
  • 🧪 Test update
  • 🔧 Chore (dependencies, config, etc.)

Service(s) Affected

  • Finance Service
  • IAM Service
  • Shared Proto (gen/)
  • Root/Common

Changes Made

💬 Real-Time Chat Engine & Chatbot (IAM)

  • Core Chat Architecture: Implemented domain entities, AES-256-GCM encryption repositories, and 16 new application handlers (Conversations, Messaging, Typing, Streaming).
  • AI Chatbot Logging: Added chatbot_audit_log repository for tracking usage of the DeepSeek assistant.
  • Chat Attachments & History: Added MinIO-backed file/image attachments (UploadAttachmentHandler) and a per-user ClearHistory feature that perfectly syncs with unread badges and conversation list previews (Migrations 000077-000078).
  • UX Data Completeness: Implemented batch loading for Read Receipts, Message Edit History, and ChatUserResolver to automatically map participant names and avatars, eliminating "Unknown User" UI issues.

🚀 Redis Streams & Performance Refactoring (IAM)

  • Redis Streams Event Bus: Upgraded the event bus from Redis Pub/Sub to Redis Streams (XADD/XREAD BLOCK) to ensure messages persist across pod restarts and can be replayed on reconnect.
  • Zero-Copy Protobuf: Eliminated JSON serialization bottlenecks in the SSE chat stream. Converted the broadcaster to use strictly typed proto binary payloads, reducing serialization hops from 5 to 2.

✉️ Offline Presence & Notifications (IAM)

  • Presence-Aware Emails: Wired a presence service backed by Redis. If a recipient is offline, the system automatically creates a TypeChat notification and dispatches an email background job (debounced with a 5-minute TTL to prevent spam).
  • Email Context: Offline emails are dynamically populated with the sender's real name and a direct CTA path pointing to the specific chat.

🐛 Bug Fixes & Chores

  • Finance Timeouts: Extended the gRPC TimeoutInterceptor deadline from 60s to 5 minutes specifically for heavy batch computes (TriggerMbBatch) and bulk imports to prevent "context deadline exceeded" rollbacks.
  • Master Key Graceful Fallback: CHAT_MASTER_KEY is now safely loaded via Viper config. If decryption fails due to a rotated/mismatched master key, the API gracefully returns an empty list instead of throwing a 500 error.
  • Permissions: Registered ChatService and PresenceService properly in the IAM Auth Interceptor and fixed schema hierarchies in the Chat menu migrations (000070-000076).

Related Issues

Fixes #
Related to #

API Changes (if applicable)

Proto Changes

+ // Added: ChatService (16 RPCs including StreamChatEvents)
+ // Added: PresenceService
+ // Updated: StreamChatEventsResponse oneof payload mappings

Breaking Changes

None.

Testing Performed

Unit Tests

  • New unit tests added (Coverage for Chat handlers, User Resolvers, and Encryption functions).
  • Existing unit tests pass
  • Coverage maintained/improved

Integration Tests

  • New integration tests added
  • Existing integration tests pass

Manual Testing

# Deployed Finance & IAM Services locally with migrations
./scripts/iam-setup.sh goapps-production migrate

# Verified real-time SSE delivery across multiple clients using Redis Streams
# Sent a message to an offline user and verified the debounced email notification arrived
# Cleared conversation history and verified the conversation list/unread count updated properly

Lint & Build

  • golangci-lint run ./... passes
  • go build ./... succeeds
  • go test -race ./... passes

Database (if applicable)

  • Migration added
    • IAM: 000070-000078 (Chat core tables, menu seeds, clear history, attachments).
  • Migration tested (up and down)
  • No breaking schema changes (or documented)

Documentation

  • README.md updated (if needed)
  • RULES.md updated (if needed)
  • Proto comments updated
  • OpenAPI regenerated

Rollback Plan

  • Rollback backend deployments for iam-service and finance-service.
  • Run down migrations in sequence for IAM (000078 down to 000070) to drop chat tables and remove the seeded menus.
  • Ensure the CHAT_MASTER_KEY environment variable remains properly mapped if rolled back to a previous testing state.

Screenshots/Logs (if applicable)


Pre-merge Checklist

  • I have read and followed RULES.md
  • I have read and followed CONTRIBUTING.md
  • Clean Architecture principles followed
  • All errors are properly handled
  • Context is passed appropriately
  • Structured logging is used
  • No hardcoded secrets (Chat Master Key is injected via env/Viper config)
  • PR description is complete and clear
  • CI checks are passing

Reviewer Notes

  • Redis Streams: The XADD command uses MAXLEN ~500 to automatically trim the stream and prevent Redis memory bloat. This is intentional as the stream is only meant for real-time delivery and short-term reconnect replays, while long-term history is fetched via standard HTTP queries from Postgres.
  • CHAT_MASTER_KEY Configuration: For Kubernetes deployment, ensure CHAT_MASTER_KEY is securely injected via goapps-chat-secret. The application will fall back to a dev-key if running locally via config.yaml, but production must use the secret to encrypt messages properly.

ilramdhan and others added 16 commits July 15, 2026 22:56
The 60s TimeoutInterceptor killed the gRPC context mid-transaction when
MB batch compute exceeded 60s, causing "context deadline exceeded" on
the job completion update. Add a longRunningMethods map that gives batch
compute and bulk import RPCs a 5-minute deadline instead of the default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Chat System:
- Domain layer: entities, value objects, repository interfaces (AES-256-GCM)
- Infrastructure: PostgreSQL repos, Redis pub/sub broadcaster, presence service
- Application: 16 handlers (conversations + messaging + typing + streaming)
- Delivery: gRPC handlers for ChatService + PresenceService
- Migrations 000070-000076: 6 tables + menu seeds/permissions
- main.go wired with conditional CHAT_MASTER_KEY activation

Chatbot:
- chatbot_audit_log repository for DeepSeek usage tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a chat message is sent, check if each recipient is online via
Redis presence. If offline, create a TypeChat notification which
triggers the existing email dispatch pipeline. Debounced per
conversation/user pair (5-minute TTL) to prevent email spam.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Chat menu: level-2 with NULL parent → level-1 root (constraint requires
  non-root menus to have a parent)
- mst_permission: use actual columns (permission_name, service_name,
  module_name, action_type) instead of plan's assumed 'name' column
- role_permissions: include required id, assigned_by columns
- Permission code: iam.chatbot.assistant.use → iam.chatbot.assistant.view
  (action_type CHECK constraint doesn't include 'use')

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…eptor

All 16 ChatService RPCs and 2 PresenceService RPCs were unmapped in
methodPermissions, causing the stream/unary permission interceptor to
deny them with "unmapped method denied". Added as authenticated-only
(empty permission) since participant-level authorization is enforced
at the handler level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ents

StreamChatEvents was sending empty proto responses (only EventId, no
payload). Now parses the broadcaster JSON payload and maps to the
proper oneof variant (MessageReceived, MessageEdited, Typing, etc.)
so the frontend SSE bridge receives typed event data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…restarts)

- Add chat.master_key to config.yaml with a dev-only default key
- Add ChatConfig struct to config loader
- Bind CHAT_MASTER_KEY env var for staging/production override
- main.go reads from cfg.Chat.MasterKey instead of os.Getenv

Local dev: key persists in config.yaml, no need to export env var.
K8s: CHAT_MASTER_KEY env var from goapps-chat-secret overrides config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n chat streaming

Replace JSON-based chat broadcaster with typed proto events:
- Event.Payload []byte → Event.Response *iamv1.StreamChatEventsResponse
- All broadcast call sites build proto directly (no json.Marshal)
- StreamChatEvents handler: zero-copy stream.Send(evt.Response)
- Redis cross-pod serialization: proto.Marshal/Unmarshal (binary)
- Delete 60 lines of JSON mapping code (broadcastPayload, buildStreamResponse)

Before: 5 serialization hops per message (JSON marshal → Redis → unmarshal → proto map → gRPC)
After: 2 hops (proto binary to Redis, ts-proto JSON at BFF SSE layer)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Redis Streams (replaces Pub/Sub):
- XADD with MAXLEN ~500 per user stream (durable, trimmed)
- XREAD BLOCK 5s for cross-pod event bridging
- Proto binary serialization via base64 encoding in stream values
- Events persist across pod restarts — replayable on reconnect

User info resolver for conversation participants:
- ChatUserResolver batch-resolves userIDs to {username, fullName, avatarUrl}
- convToProto now populates ParticipantProto.Username/FullName/AvatarUrl
- Fixes "Unknown User" / "UN" initials in conversation list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rors

ListMessages: if conversation key decryption fails (master key changed
between sessions), return empty list instead of 500 error. Logs the
mismatch as a warning.

mapChatError: log the actual error before returning "internal error"
so backend logs show the root cause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CreateHandler only persists + SSE-broadcasts notifications — it never
sends email. Added EmailDispatcher to SendMessageHandler and call
Dispatch() in a background goroutine after creating the offline
notification. Wired notifEmailDispatcher from main.go.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- broadcastMessageEvent: accept senderName param, set MessageProto.SenderName
- resolveSenderName helper: shared between send + edit handlers
- SendMessage: resolve sender name before broadcast + offline notification
- EditMessage: resolve original sender name for edit broadcast
- Offline email: title="{name} sent you a message", body="{name}: {text}",
  CTA label="Reply" pointing to /chat
- WithOfflineNotification: accept ChatUserResolver for name resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ail CTA

Bug A: Read receipts in ListMessages — batch-load receipts for all
  messages in one query (no N+1), attach to each message before return.

Bug B: LastMessage + UnreadCount in conversation list — new repo methods
  GetUnreadCounts + GetLastMessages (batched), ListConversationsHandler
  returns ConversationSummary with decrypted last message body + count.
  Frontend conversation list now shows persistent previews from API.

Bug C: GetMessageEditHistory — new application handler + gRPC method.
  Verifies caller is participant, decrypts old message bodies from
  chat_message_edit_history table.

Bug D: Email CTA — ActionPayload key fixed from "url" to "path" to
  match NotificationDispatcher.buildCTAURL expected contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
msgToProto never set SenderName — messages fetched via ListMessages
had empty sender names. SSE-pushed messages had names (via
broadcastMessageEvent) but refetch would overwrite them with empty.

Batch-resolve sender names via ChatUserResolver for the message page,
same pattern as convToProto participant name resolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement the backend for the three remaining chat features on top of the
regenerated proto bindings.

Clear history (per-user):
- migration 000077 adds chat_participant.history_cleared_at.
- Participant.HistoryClearedAt + ClearHistory repo method; ListByConversation
  and MarkRead now honor the per-user cleared-history boundary.
- ClearHistoryHandler + ClearConversationHistory gRPC method.

Attachments:
- migration 000078 adds the chat_attachment table (message_id nullable until
  the message is sent).
- domain Attachment aggregate + AttachmentRepository, postgres repository, and
  MinIO storage.UploadChatAttachment ({basePath}/chat/attachments/{conv}/...).
- UploadAttachmentHandler (participant check, nil-storage guard, image
  thumbnail); SendMessage links pre-uploaded attachments and ListMessages
  batch-loads them; real-time broadcast carries attachments.

Both features are registered as authenticated-only in the permission
interceptor and wired in cmd/server/main.go.

Authored-By: Ilham R <ilhamram332@gmail.com>
Co-Authored-By: Ilham R <me@ilramdhan.dev>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: IT Mutugading <it@mutugading.com>
Co-Authored-By: Indra Putro <indraputro@mutugading.com>
Co-Authored-By: Ilham R <ilham.ramadhan@mutugading.com>
Co-Authored-By: IT Mutu Gading <mutugadingapps@gmail.com>
…ount

GetLastMessages and GetUnreadCounts ignored chat_participant.history_cleared_at,
so after a user cleared a conversation the list preview still showed the old
last message and its unread badge, even though the opened thread was correctly
empty.

- GetLastMessages now takes viewerID and joins the viewer's participant row,
  excluding messages created at or before their history_cleared_at.
- GetUnreadCounts applies the same history_cleared_at boundary.

Authored-By: Ilham R <ilhamram332@gmail.com>
Co-Authored-By: Ilham R <me@ilramdhan.dev>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: IT Mutugading <it@mutugading.com>
Co-Authored-By: Indra Putro <indraputro@mutugading.com>
Co-Authored-By: Ilham R <ilham.ramadhan@mutugading.com>
Co-Authored-By: IT Mutu Gading <mutugadingapps@gmail.com>
@ilramdhan ilramdhan added this to the Costing Release Milestone milestone Jul 16, 2026
@ilramdhan ilramdhan self-assigned this Jul 16, 2026
Copilot AI review requested due to automatic review settings July 16, 2026 17:19
@ilramdhan ilramdhan added bug Something isn't working documentation Improvements or additions to documentation labels Jul 16, 2026
@ilramdhan ilramdhan added enhancement New feature or request fix feat labels Jul 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

CI lint (v2.3.0) caught issues the local v1.62 binary missed. Fix all 15:

- errcheck: wrap defer rows.Close() in logging closures (conversation,
  message, user-resolver repos); nolint best-effort ResolveUsers name
  enrichment; nolint comma-ok map type assertions in the broadcaster.
- errorlint: use errors.Is(err, redis.Nil) instead of == in the broadcaster.
- gocognit/nestif: extract initChatServices from run(), and split the chat
  message page query into queryMessagePage/queryFirstPage/queryCursorPage.
- nilnil: ResolveUsers returns an empty map (not nil,nil) on empty input.
- revive package-comments: add package docs to application/chat and
  infrastructure/crypto.

Verified locally with golangci-lint v2.3.0: 0 issues. Build, vet, and chat
tests all pass.

Authored-By: Ilham R <ilhamram332@gmail.com>
Co-Authored-By: Ilham R <me@ilramdhan.dev>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: IT Mutugading <it@mutugading.com>
Co-Authored-By: Indra Putro <indraputro@mutugading.com>
Co-Authored-By: Ilham R <ilham.ramadhan@mutugading.com>
Co-Authored-By: IT Mutu Gading <mutugadingapps@gmail.com>
@ilramdhan
ilramdhan merged commit c0dbd21 into mutugading:main Jul 16, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request feat fix

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants