Skip to content

Chat

Purpose

ChatService powers every messaging surface in the app: per-event group chats (one room auto-created per event), 1:1 direct messages, freeform group chats, and the system messages that announce member joins / leaves / setting changes. It owns persistence (PostgreSQL via GORM), the Redis cache + pub/sub layer, WebSocket broadcast (via a BroadcastFunc callback so it doesn't import the hub), reactions, edit/delete, search, and the disappearing-messages feature. MessagePurgerService is a separate, lean service whose only job is to remove messages from event chat rooms whose retention window has elapsed.

Responsibilities

  • Validate and persist messages (PostgreSQL); cache last 100 messages per room in Redis (chat:messages:{roomId})
  • Publish messages to Redis Pub/Sub (chat:event:{roomId}) and broadcast over WebSocket
  • Resolve reply_to references and inline a ReplyToInfo preview on the message response
  • Handle reactions (one emoji per user per message, toggle on/off)
  • Edit and soft-delete messages (re-published to subscribers as message_updated / message_deleted)
  • Mark-read + unread count per user per room (denormalised last_read_at)
  • Group + 1:1 room CRUD: GetOrCreateChatRoom, CreateGroupChat, UpdateChatRoom, AddMember, RemoveMember, SetNickname, SetMuted
  • Upload group avatar and chat image attachments (via storage.FileStorage)
  • Search across messages in a single room and across all rooms a user is in
  • Compute and enforce expires_at for disappearing messages (off, seen, 24h, 7d)
  • CleanupExpiredMessages — invoked by the message_expiry cron (every 30s) to hard-delete expired messages and emit messages_expired payloads to connected clients
  • MessagePurgerService — bulk delete messages for events past retention (purge cron, 24h)

HTTP and WebSocket endpoints

WebSocket

Method Path Auth Description
WS /ws/chat/:eventId JWT (query/header) Subscribes to a room (event ID or chat room ID). Supports send_message, ping, mark_read, add_reaction, edit_message, delete_message.

Event chat (HTTP fallback / history)

Method Path Auth Description
GET /api/v1/chat/search JWT Search messages across all the user's rooms
GET /api/v1/chat/:eventId/messages JWT Paginated messages for a room
GET /api/v1/chat/:eventId/messages/search JWT Search within a single room
POST /api/v1/chat/:eventId/messages JWT HTTP send (mirror of WS send_message)
POST /api/v1/chat/:eventId/read JWT Mark room as read
POST /api/v1/chat/:eventId/messages/:messageId/react JWT Toggle reaction
POST /api/v1/chat/:eventId/upload-image JWT Upload chat image attachment

Direct messages and group chats

Method Path Auth Description
GET /api/v1/dm/room/:userId JWT Get-or-create a 1:1 room with userId
GET /api/v1/dm/rooms JWT Paginated list of the user's rooms (with last-message preview)
GET /api/v1/dm/rooms/:id JWT Single room metadata
POST /api/v1/dm/group JWT Create a group chat from a member list
GET /api/v1/dm/rooms/:id/settings JWT Settings + participants info
PATCH /api/v1/dm/rooms/:id JWT Update room (name, disappearing_messages, etc.)
POST /api/v1/dm/rooms/:id/members JWT Add member (creator/admin only)
DELETE /api/v1/dm/rooms/:id/members/:userId JWT Remove member
PATCH /api/v1/dm/rooms/:id/nickname JWT Set the caller's own nickname in the room
PATCH /api/v1/dm/rooms/:id/mute JWT Mute/unmute the room for the caller
POST /api/v1/dm/rooms/:id/avatar JWT Upload group avatar

Key types

type ChatService struct {
    db           *gorm.DB
    redisService RedisService
    eventRepo    repository.EventRepository
    userRepo     repository.UserRepository
    friendRepo   repository.FriendRepository
    broadcastFn  BroadcastFunc
}

// Injected after Hub init to avoid a cycle.
type BroadcastFunc func(chatID uuid.UUID, msgType string, data interface{})

type ChatRoomResponse struct {
    ID                   string
    Type                 string             // "direct" | "group"
    OtherUser            ChatRoomUser
    LastMessage          *LastMessageInfo
    UnreadCount          int64
    EventID              string
    Participants         []ChatRoomUser     // first 4 — used for the avatar mosaic
    AvatarURL            string
    DisappearingMessages string             // "off" | "seen" | "24h" | "7d"
    ReadReceiptsEnabled  bool
    IsMuted              bool
}

type MessagePurgerService interface {
    PurgeEventMessages(ctx context.Context) error
    PurgeMessagesForEvent(ctx context.Context, eventID string) error
    PurgeMessagesForEvents(ctx context.Context, eventIDs []string) error
}

Data model

Table Notes
chat_rooms type (direct / group), event_id (set for event rooms), disappearing_messages, disappearing_since, last-message denorm columns, avatar_url
chat_room_participants composite (chat_room_id, user_id), role (admin / member), nickname, is_muted, last_read_at, seen_expires_at
chat_messages chat_room_id, user_id (nullable for anonymised users), content, type (message / system), reply_to_id, edited_at, deleted_at, expires_at
message_reactions (message_id, user_id, emoji) uniqueness

Dependencies

  • RedisService — message cache (LIST), pub/sub channel, online presence per room
  • storage.FileStorage — group avatars, chat image attachments
  • WebSocket Hub (internal/websocket/) — wired via SetBroadcastFunc
  • repository.EventRepository / UserRepository / FriendRepository

Notable behavior

Three room flavours, one table

Event chats, 1:1 DMs, and group chats all live in the same chat_rooms table. Event rooms carry event_id; DMs are type='direct'; freeform groups are type='group' with no event_id. WebSocket subscribers can pass either an event ID or a chat-room UUID — CheckParticipation handles both.

Forward-only disappearing messages

Toggling disappearing_messages only affects future messages. disappearing_since records when the setting was last enabled; calcExpiresAt(createdAt, setting) stamps expires_at on new messages. Historic messages without an expires_at are never expired retroactively.

Two cleanup paths

  • message_expiry cron (30s) → ChatService.CleanupExpiredMessages for disappearing-message hard-delete + messages_expired broadcast
  • purge cron (24h) → MessagePurgerService.PurgeEventMessages for event chats past end_time + retention_days. Friend chat rooms are never purged here.

Anonymised senders survive user deletion

When a user is hard-deleted (see Users), user_id on their group-chat messages is set to NULL so other members can still read the conversation. The frontend renders null senders as "Deleted user".

Where to look