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_toreferences and inline aReplyToInfopreview 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_atfor disappearing messages (off,seen,24h,7d) CleanupExpiredMessages— invoked by themessage_expirycron (every 30s) to hard-delete expired messages and emitmessages_expiredpayloads to connected clientsMessagePurgerService— bulk delete messages for events past retention (purgecron, 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 roomstorage.FileStorage— group avatars, chat image attachments- WebSocket Hub (
internal/websocket/) — wired viaSetBroadcastFunc 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_expirycron (30s) →ChatService.CleanupExpiredMessagesfor disappearing-message hard-delete +messages_expiredbroadcastpurgecron (24h) →MessagePurgerService.PurgeEventMessagesfor event chats pastend_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¶
backend/internal/services/chat_service.gobackend/internal/services/message_purger_service.gobackend/internal/services/system_messages.go—SystemMessageencoding helpersbackend/internal/handlers/chat_handler.gobackend/internal/websocket/— Hub, client, broadcast helpersbackend/internal/models/chat_room.go,chat_message.gobackend/internal/scheduler/handlers.go—message_expirytask handler