• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DigitalTolk / ex / 24964496916

26 Apr 2026 06:58PM UTC coverage: 90.069% (-0.3%) from 90.372%
24964496916

push

github

web-flow
Docker fixes (#13)

* Docker fixes

* test

1506 of 1799 branches covered (83.71%)

Branch coverage included in aggregate %.

955 of 1086 new or added lines in 34 files covered. (87.94%)

7935 of 8683 relevant lines covered (91.39%)

18.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.67
/internal/service/message.go
1
package service
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "log/slog"
8
        "sort"
9
        "sync"
10
        "time"
11

12
        "github.com/DigitalTolk/ex/internal/events"
13
        "github.com/DigitalTolk/ex/internal/model"
14
        "github.com/DigitalTolk/ex/internal/pubsub"
15
        "github.com/DigitalTolk/ex/internal/store"
16
)
17

18
// Parent type constants used by handlers to indicate whether the parent is a
19
// channel or a conversation.
20
const (
21
        ParentChannel      = "channel"
22
        ParentConversation = "conversation"
23
)
24

25
// ConversationActivator is implemented by ConversationService and lets
26
// MessageService activate a conversation on first message send.
27
type ConversationActivator interface {
28
        Activate(ctx context.Context, convID string) error
29
}
30

31
// AttachmentRefManager is the AttachmentService capability MessageService uses
32
// to bind/unbind attachments to messages. Defined as an interface so tests can
33
// stub it without dragging in storage.
34
type AttachmentRefManager interface {
35
        AddRef(ctx context.Context, attachmentID, messageID string) error
36
        RemoveRef(ctx context.Context, attachmentID, messageID string) error
37
}
38

39
// MessageNotifier is the slice of NotificationService MessageService cares
40
// about. Defined as an interface so the dependency is explicit and tests
41
// can stub it without instantiating the real notifier.
42
type MessageNotifier interface {
43
        NotifyForMessage(ctx context.Context, msg *model.Message, parentType string)
44
}
45

46
// MessageService handles sending, editing, deleting, and listing messages.
47
type MessageService struct {
48
        messages      MessageStore
49
        memberships   MembershipStore
50
        conversations ConversationStore
51
        publisher     Publisher
52
        broker        Broker
53
        activator     ConversationActivator
54
        attachments   AttachmentRefManager
55
        notifier      MessageNotifier
56
}
57

58
// NewMessageService creates a MessageService with the given dependencies.
59
func NewMessageService(
60
        messages MessageStore,
61
        memberships MembershipStore,
62
        conversations ConversationStore,
63
        publisher Publisher,
64
        broker Broker,
65
) *MessageService {
52✔
66
        return &MessageService{
52✔
67
                messages:      messages,
52✔
68
                memberships:   memberships,
52✔
69
                conversations: conversations,
52✔
70
                publisher:     publisher,
52✔
71
                broker:        broker,
52✔
72
        }
52✔
73
}
52✔
74

75
// SetActivator wires the conversation activator. Called from main wiring after
76
// both services are constructed to avoid a constructor cycle.
77
func (s *MessageService) SetActivator(a ConversationActivator) { s.activator = a }
1✔
78

79
// SetAttachmentManager wires the attachment ref manager. Called from main
80
// wiring after both services are constructed to avoid a constructor cycle.
81
func (s *MessageService) SetAttachmentManager(a AttachmentRefManager) { s.attachments = a }
4✔
82

83
// SetNotifier wires the notification dispatcher. Optional — when nil, no
84
// alerts are produced and message sends still complete normally.
85
func (s *MessageService) SetNotifier(n MessageNotifier) { s.notifier = n }
×
86

87
// Send creates a new message in the given parent (channel or conversation).
88
// If parentMessageID is non-empty, the message is a thread reply: the root
89
// message's ReplyCount is incremented and a message.edited event is published
90
// for the root so the UI updates the count.
91
//
92
// Attachments are bound by ID after the message row is persisted so dangling
93
// refs are impossible.
94
func (s *MessageService) Send(ctx context.Context, userID, parentID, parentType, body, parentMessageID string, attachmentIDs ...string) (*model.Message, error) {
16✔
95
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
21✔
96
                return nil, err
5✔
97
        }
5✔
98

99
        if body == "" && len(attachmentIDs) == 0 {
11✔
100
                return nil, errors.New("message: body or attachments required")
×
101
        }
×
102

103
        now := time.Now()
11✔
104
        msg := &model.Message{
11✔
105
                ID:              store.NewID(),
11✔
106
                ParentID:        parentID,
11✔
107
                AuthorID:        userID,
11✔
108
                Body:            body,
11✔
109
                ParentMessageID: parentMessageID,
11✔
110
                AttachmentIDs:   attachmentIDs,
11✔
111
                CreatedAt:       now,
11✔
112
        }
11✔
113

11✔
114
        if err := s.messages.CreateMessage(ctx, msg); err != nil {
12✔
115
                return nil, fmt.Errorf("message: create: %w", err)
1✔
116
        }
1✔
117

118
        // Bind each attachment to this message. Failures are logged but the
119
        // message is already persisted so we don't roll it back.
120
        s.bindAttachments(ctx, msg.ID, attachmentIDs)
10✔
121

10✔
122
        // Activate the conversation on first message so non-creator participants
10✔
123
        // see it appear in their sidebars only after activity exists.
10✔
124
        if parentType == ParentConversation && s.activator != nil && parentMessageID == "" {
11✔
125
                if err := s.activator.Activate(ctx, parentID); err != nil {
1✔
126
                        slog.Warn("conversation activate failed", "convID", parentID, "error", err)
×
127
                }
×
128
        }
129

130
        s.publishEvent(ctx, parentID, parentType, events.EventMessageNew, msg)
10✔
131

10✔
132
        // Fire user-facing notifications (sound + popup) to recipients who
10✔
133
        // haven't muted the parent. Decoupled from event publishing so failure
10✔
134
        // here never affects state propagation.
10✔
135
        if s.notifier != nil {
10✔
136
                s.notifier.NotifyForMessage(ctx, msg, parentType)
×
137
        }
×
138

139
        // Mentioning a user who isn't yet in the channel surfaces a system
140
        // message inviting whoever can to add them. Channel-only — DMs and
141
        // groups can't mention "outsiders" since there's no concept of one.
142
        if parentType == ParentChannel {
17✔
143
                s.flagNonMemberMentions(ctx, msg)
7✔
144
        }
7✔
145

146
        // If this is a thread reply, bump the root message's ReplyCount and emit
147
        // an edited event so subscribed clients update the count.
148
        if parentMessageID != "" {
11✔
149
                if parent, err := s.messages.GetMessage(ctx, parentID, parentMessageID); err == nil && parent != nil {
2✔
150
                        parent.ReplyCount++
1✔
151
                        if err := s.messages.UpdateMessage(ctx, parent); err == nil {
2✔
152
                                s.publishEvent(ctx, parentID, parentType, events.EventMessageEdited, parent)
1✔
153
                        }
1✔
154
                }
155
        }
156

157
        return msg, nil
10✔
158
}
159

160
// ListThreadMessages returns the root message followed by all reply messages
161
// for a thread, in chronological order (oldest first). ULIDs sort by timestamp,
162
// so we sort by ID ascending — the underlying ListMessages returns descending.
163
func (s *MessageService) ListThreadMessages(ctx context.Context, userID, parentID, parentType, threadRootID string) ([]*model.Message, error) {
4✔
164
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
5✔
165
                return nil, err
1✔
166
        }
1✔
167
        msgs, _, err := s.messages.ListMessages(ctx, parentID, "", 1000)
3✔
168
        if err != nil {
4✔
169
                return nil, fmt.Errorf("message: list thread: %w", err)
1✔
170
        }
1✔
171
        thread := make([]*model.Message, 0)
2✔
172
        for _, m := range msgs {
7✔
173
                if m.ID == threadRootID || m.ParentMessageID == threadRootID {
9✔
174
                        thread = append(thread, m)
4✔
175
                }
4✔
176
        }
177
        sort.Slice(thread, func(i, j int) bool { return thread[i].ID < thread[j].ID })
5✔
178
        return thread, nil
2✔
179
}
180

181
// ThreadSummary describes a thread the user has participated in. It carries
182
// the metadata the sidebar needs (where to navigate, what to show, when the
183
// last activity was) without forcing the client to make N follow-up queries.
184
type ThreadSummary struct {
185
        ParentID         string    `json:"parentID"`
186
        ParentType       string    `json:"parentType"`
187
        ThreadRootID     string    `json:"threadRootID"`
188
        RootAuthorID     string    `json:"rootAuthorID"`
189
        RootBody         string    `json:"rootBody"`
190
        RootCreatedAt    time.Time `json:"rootCreatedAt"`
191
        ReplyCount       int       `json:"replyCount"`
192
        LatestActivityAt time.Time `json:"latestActivityAt"`
193
}
194

195
// ListUserThreads returns thread summaries for every thread the given user has
196
// participated in (authored the root or any reply). Sorted by latest activity,
197
// newest first.
198
//
199
// This walks the parents the user has access to (channels they're a member of
200
// and conversations they participate in) and inspects recent messages — the
201
// app targets small workspaces so this is acceptable. For larger scale this
202
// would move to a dedicated thread-participation index.
203
func (s *MessageService) ListUserThreads(ctx context.Context, userID string) ([]*ThreadSummary, error) {
1✔
204
        type parentRef struct {
1✔
205
                id  string
1✔
206
                typ string
1✔
207
        }
1✔
208
        parents := make([]parentRef, 0, 32)
1✔
209

1✔
210
        if s.memberships != nil {
2✔
211
                channels, err := s.memberships.ListUserChannels(ctx, userID)
1✔
212
                if err != nil {
1✔
213
                        return nil, fmt.Errorf("threads: list channels: %w", err)
×
214
                }
×
215
                for _, c := range channels {
2✔
216
                        parents = append(parents, parentRef{id: c.ChannelID, typ: ParentChannel})
1✔
217
                }
1✔
218
        }
219
        if s.conversations != nil {
2✔
220
                convs, err := s.conversations.ListUserConversations(ctx, userID)
1✔
221
                if err != nil {
1✔
222
                        return nil, fmt.Errorf("threads: list conversations: %w", err)
×
223
                }
×
224
                for _, c := range convs {
1✔
225
                        parents = append(parents, parentRef{id: c.ConversationID, typ: ParentConversation})
×
226
                }
×
227
        }
228

229
        out := make([]*ThreadSummary, 0)
1✔
230
        seen := make(map[string]bool)
1✔
231

1✔
232
        for _, p := range parents {
2✔
233
                msgs, _, err := s.messages.ListMessages(ctx, p.id, "", 1000)
1✔
234
                if err != nil {
1✔
235
                        continue
×
236
                }
237
                // Index messages by ID so we can resolve thread roots without a second fetch.
238
                byID := make(map[string]*model.Message, len(msgs))
1✔
239
                for _, m := range msgs {
7✔
240
                        byID[m.ID] = m
6✔
241
                }
6✔
242
                // Collect thread roots the user participates in for this parent.
243
                participated := make(map[string]bool)
1✔
244
                for _, m := range msgs {
7✔
245
                        if m.AuthorID != userID {
10✔
246
                                continue
4✔
247
                        }
248
                        if m.ParentMessageID != "" {
3✔
249
                                participated[m.ParentMessageID] = true
1✔
250
                        } else if m.ReplyCount > 0 {
3✔
251
                                participated[m.ID] = true
1✔
252
                        }
1✔
253
                }
254
                // Build summaries.
255
                for rootID := range participated {
3✔
256
                        key := p.id + "#" + rootID
2✔
257
                        if seen[key] {
2✔
258
                                continue
×
259
                        }
260
                        seen[key] = true
2✔
261
                        root := byID[rootID]
2✔
262
                        if root == nil {
2✔
263
                                continue
×
264
                        }
265
                        latest := root.CreatedAt
2✔
266
                        for _, m := range msgs {
14✔
267
                                if m.ParentMessageID == rootID && m.CreatedAt.After(latest) {
15✔
268
                                        latest = m.CreatedAt
3✔
269
                                }
3✔
270
                        }
271
                        out = append(out, &ThreadSummary{
2✔
272
                                ParentID:         p.id,
2✔
273
                                ParentType:       p.typ,
2✔
274
                                ThreadRootID:     rootID,
2✔
275
                                RootAuthorID:     root.AuthorID,
2✔
276
                                RootBody:         root.Body,
2✔
277
                                RootCreatedAt:    root.CreatedAt,
2✔
278
                                ReplyCount:       root.ReplyCount,
2✔
279
                                LatestActivityAt: latest,
2✔
280
                        })
2✔
281
                }
282
        }
283

284
        sort.Slice(out, func(i, j int) bool {
2✔
285
                return out[i].LatestActivityAt.After(out[j].LatestActivityAt)
1✔
286
        })
1✔
287
        return out, nil
1✔
288
}
289

290
// ListPinned returns all currently-pinned messages for a parent in
291
// reverse-chronological order (newest pin first by message ID). Membership
292
// is checked via the parent's access guard.
293
func (s *MessageService) ListPinned(ctx context.Context, userID, parentID, parentType string) ([]*model.Message, error) {
2✔
294
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
3✔
295
                return nil, err
1✔
296
        }
1✔
297
        msgs, _, err := s.messages.ListMessages(ctx, parentID, "", 1000)
1✔
298
        if err != nil {
1✔
299
                return nil, fmt.Errorf("message: list pinned: %w", err)
×
300
        }
×
301
        pinned := make([]*model.Message, 0)
1✔
302
        for _, m := range msgs {
4✔
303
                if m.Pinned {
5✔
304
                        pinned = append(pinned, m)
2✔
305
                }
2✔
306
        }
307
        return pinned, nil
1✔
308
}
309

310
// List returns messages for a parent with cursor-based pagination.
311
// It returns the messages, a boolean indicating whether there are more
312
// results, and any error.
313
func (s *MessageService) List(ctx context.Context, userID, parentID, parentType, before string, limit int) ([]*model.Message, bool, error) {
3✔
314
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
4✔
315
                return nil, false, err
1✔
316
        }
1✔
317

318
        msgs, hasMore, err := s.messages.ListMessages(ctx, parentID, before, limit)
2✔
319
        if err != nil {
3✔
320
                return nil, false, fmt.Errorf("message: list: %w", err)
1✔
321
        }
1✔
322
        return msgs, hasMore, nil
1✔
323
}
324

325
// Edit updates the body and (optionally) the attachment list of an existing
326
// message. Only the original author may edit. If attachmentIDs is nil, the
327
// existing attachments are preserved; if non-nil (even an empty slice) the
328
// attachments are replaced wholesale and add/remove refs are reconciled.
329
func (s *MessageService) Edit(ctx context.Context, userID, parentID, parentType, msgID, newBody string, attachmentIDs []string) (*model.Message, error) {
8✔
330
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
9✔
331
                return nil, err
1✔
332
        }
1✔
333

334
        msg, err := s.messages.GetMessage(ctx, parentID, msgID)
7✔
335
        if err != nil {
8✔
336
                return nil, fmt.Errorf("message: get: %w", err)
1✔
337
        }
1✔
338

339
        if msg.AuthorID != userID {
7✔
340
                return nil, errors.New("message: only the author can edit")
1✔
341
        }
1✔
342

343
        finalAttachments := msg.AttachmentIDs
5✔
344
        if attachmentIDs != nil {
7✔
345
                finalAttachments = attachmentIDs
2✔
346
        }
2✔
347
        if newBody == "" && len(finalAttachments) == 0 {
6✔
348
                return nil, errors.New("message: body or attachments required")
1✔
349
        }
1✔
350

351
        msg.Body = newBody
4✔
352
        now := time.Now()
4✔
353
        msg.EditedAt = &now
4✔
354

4✔
355
        var added, removed []string
4✔
356
        if attachmentIDs != nil {
5✔
357
                prev := map[string]bool{}
1✔
358
                for _, id := range msg.AttachmentIDs {
3✔
359
                        prev[id] = true
2✔
360
                }
2✔
361
                next := map[string]bool{}
1✔
362
                for _, id := range attachmentIDs {
3✔
363
                        if id == "" || next[id] {
2✔
364
                                continue
×
365
                        }
366
                        next[id] = true
2✔
367
                        if !prev[id] {
3✔
368
                                added = append(added, id)
1✔
369
                        }
1✔
370
                }
371
                for id := range prev {
3✔
372
                        if !next[id] {
3✔
373
                                removed = append(removed, id)
1✔
374
                        }
1✔
375
                }
376
                // Replace with deduped, ordered new list.
377
                clean := make([]string, 0, len(attachmentIDs))
1✔
378
                seen := map[string]bool{}
1✔
379
                for _, id := range attachmentIDs {
3✔
380
                        if id == "" || seen[id] {
2✔
381
                                continue
×
382
                        }
383
                        seen[id] = true
2✔
384
                        clean = append(clean, id)
2✔
385
                }
386
                msg.AttachmentIDs = clean
1✔
387
        }
388

389
        if err := s.messages.UpdateMessage(ctx, msg); err != nil {
5✔
390
                return nil, fmt.Errorf("message: update: %w", err)
1✔
391
        }
1✔
392

393
        // Reconcile attachment refcounts in parallel; failures are logged inside
394
        // bindAttachments / releaseAttachments and do not roll back the edit.
395
        s.bindAttachments(ctx, msgID, added)
3✔
396
        s.releaseAttachments(ctx, msgID, removed)
3✔
397

3✔
398
        s.publishEvent(ctx, parentID, parentType, events.EventMessageEdited, msg)
3✔
399

3✔
400
        return msg, nil
3✔
401
}
402

403
// Delete removes a message. The author or a channel admin (for channel
404
// messages) may delete.
405
func (s *MessageService) Delete(ctx context.Context, userID, parentID, parentType, msgID string) error {
9✔
406
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
10✔
407
                return err
1✔
408
        }
1✔
409

410
        msg, err := s.messages.GetMessage(ctx, parentID, msgID)
8✔
411
        if err != nil {
9✔
412
                return fmt.Errorf("message: get: %w", err)
1✔
413
        }
1✔
414

415
        if msg.AuthorID != userID {
10✔
416
                // For channel messages, allow admins to delete.
3✔
417
                if parentType == ParentChannel {
5✔
418
                        mem, err := s.memberships.GetMembership(ctx, parentID, userID)
2✔
419
                        if err != nil || mem.Role < model.ChannelRoleAdmin {
3✔
420
                                return errors.New("message: only the author or a channel admin can delete")
1✔
421
                        }
1✔
422
                } else {
1✔
423
                        return errors.New("message: only the author can delete")
1✔
424
                }
1✔
425
        }
426

427
        if err := s.messages.DeleteMessage(ctx, parentID, msgID); err != nil {
6✔
428
                return fmt.Errorf("message: delete: %w", err)
1✔
429
        }
1✔
430

431
        s.releaseAttachments(ctx, msgID, msg.AttachmentIDs)
4✔
432

4✔
433
        payload := struct {
4✔
434
                ID       string `json:"id"`
4✔
435
                ParentID string `json:"parentID"`
4✔
436
        }{ID: msgID, ParentID: parentID}
4✔
437
        s.publishEvent(ctx, parentID, parentType, events.EventMessageDeleted, payload)
4✔
438

4✔
439
        return nil
4✔
440
}
441

442
// ToggleReaction adds the given emoji from the user to a message, or removes
443
// it if the user has already reacted with that emoji. The updated message is
444
// persisted and a message.edited event is published so all clients refresh.
445
func (s *MessageService) ToggleReaction(ctx context.Context, userID, parentID, parentType, msgID, emoji string) (*model.Message, error) {
9✔
446
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
10✔
447
                return nil, err
1✔
448
        }
1✔
449
        if emoji == "" {
9✔
450
                return nil, errors.New("message: emoji required")
1✔
451
        }
1✔
452

453
        msg, err := s.messages.GetMessage(ctx, parentID, msgID)
7✔
454
        if err != nil {
8✔
455
                return nil, fmt.Errorf("message: get: %w", err)
1✔
456
        }
1✔
457

458
        if msg.Reactions == nil {
9✔
459
                msg.Reactions = map[string][]string{}
3✔
460
        }
3✔
461
        users := msg.Reactions[emoji]
6✔
462
        idx := -1
6✔
463
        for i, u := range users {
9✔
464
                if u == userID {
5✔
465
                        idx = i
2✔
466
                        break
2✔
467
                }
468
        }
469
        if idx >= 0 {
8✔
470
                users = append(users[:idx], users[idx+1:]...)
2✔
471
                if len(users) == 0 {
3✔
472
                        delete(msg.Reactions, emoji)
1✔
473
                } else {
2✔
474
                        msg.Reactions[emoji] = users
1✔
475
                }
1✔
476
        } else {
4✔
477
                msg.Reactions[emoji] = append(users, userID)
4✔
478
        }
4✔
479
        if len(msg.Reactions) == 0 {
7✔
480
                msg.Reactions = nil
1✔
481
        }
1✔
482

483
        if err := s.messages.UpdateMessage(ctx, msg); err != nil {
7✔
484
                return nil, fmt.Errorf("message: update: %w", err)
1✔
485
        }
1✔
486

487
        s.publishEvent(ctx, parentID, parentType, events.EventMessageEdited, msg)
5✔
488
        return msg, nil
5✔
489
}
490

491
// SetPinned toggles the pinned state of a message. Any participant in the
492
// channel/conversation may pin or unpin — pin authorship is captured on
493
// the message itself and serves as the audit trail.
494
func (s *MessageService) SetPinned(ctx context.Context, userID, parentID, parentType, msgID string, pinned bool) (*model.Message, error) {
4✔
495
        if err := s.checkAccess(ctx, userID, parentID, parentType); err != nil {
5✔
496
                return nil, err
1✔
497
        }
1✔
498
        msg, err := s.messages.GetMessage(ctx, parentID, msgID)
3✔
499
        if err != nil {
3✔
500
                return nil, fmt.Errorf("message: get: %w", err)
×
501
        }
×
502
        if msg.Pinned == pinned {
4✔
503
                return msg, nil
1✔
504
        }
1✔
505
        msg.Pinned = pinned
2✔
506
        if pinned {
3✔
507
                now := time.Now()
1✔
508
                msg.PinnedAt = &now
1✔
509
                msg.PinnedBy = userID
1✔
510
        } else {
2✔
511
                msg.PinnedAt = nil
1✔
512
                msg.PinnedBy = ""
1✔
513
        }
1✔
514
        if err := s.messages.UpdateMessage(ctx, msg); err != nil {
2✔
515
                return nil, fmt.Errorf("message: update pinned: %w", err)
×
516
        }
×
517
        // Re-use message.edited so existing message-list invalidation paths
518
        // pick up the change without a new event handler. Pin is rare enough
519
        // that a dedicated event would be over-engineered.
520
        s.publishEvent(ctx, parentID, parentType, events.EventMessageEdited, msg)
2✔
521
        return msg, nil
2✔
522
}
523

524
// checkAccess verifies the user is a member of the channel or a participant
525
// in the conversation.
526
func (s *MessageService) checkAccess(ctx context.Context, userID, parentID, parentType string) error {
55✔
527
        switch parentType {
55✔
528
        case ParentChannel:
47✔
529
                _, err := s.memberships.GetMembership(ctx, parentID, userID)
47✔
530
                if err != nil {
56✔
531
                        if errors.Is(err, store.ErrNotFound) {
17✔
532
                                return errors.New("message: not a channel member")
8✔
533
                        }
8✔
534
                        return fmt.Errorf("message: check channel membership: %w", err)
1✔
535
                }
536
        case ParentConversation:
7✔
537
                conv, err := s.conversations.GetConversation(ctx, parentID)
7✔
538
                if err != nil {
8✔
539
                        return fmt.Errorf("message: get conversation: %w", err)
1✔
540
                }
1✔
541
                found := false
6✔
542
                for _, id := range conv.ParticipantIDs {
15✔
543
                        if id == userID {
14✔
544
                                found = true
5✔
545
                                break
5✔
546
                        }
547
                }
548
                if !found {
7✔
549
                        return errors.New("message: not a conversation participant")
1✔
550
                }
1✔
551
        default:
1✔
552
                return fmt.Errorf("message: unknown parent type %q", parentType)
1✔
553
        }
554
        return nil
43✔
555
}
556

557
// bindAttachments fans out AddRef calls in parallel and logs (but does not
558
// surface) any per-attachment failure — the message is already persisted.
559
func (s *MessageService) bindAttachments(ctx context.Context, msgID string, ids []string) {
13✔
560
        if s.attachments == nil || len(ids) == 0 {
23✔
561
                return
10✔
562
        }
10✔
563
        var wg sync.WaitGroup
3✔
564
        for _, aid := range ids {
6✔
565
                if aid == "" {
3✔
566
                        continue
×
567
                }
568
                wg.Add(1)
3✔
569
                go func(aid string) {
6✔
570
                        defer wg.Done()
3✔
571
                        if err := s.attachments.AddRef(ctx, aid, msgID); err != nil {
3✔
572
                                slog.Warn("attachment add ref failed", "attID", aid, "msgID", msgID, "error", err)
×
573
                        }
×
574
                }(aid)
575
        }
576
        wg.Wait()
3✔
577
}
578

579
// releaseAttachments mirrors bindAttachments but for RemoveRef. Run on message
580
// delete so unreferenced uploads are GC'd from S3.
581
func (s *MessageService) releaseAttachments(ctx context.Context, msgID string, ids []string) {
7✔
582
        if s.attachments == nil || len(ids) == 0 {
12✔
583
                return
5✔
584
        }
5✔
585
        var wg sync.WaitGroup
2✔
586
        for _, aid := range ids {
4✔
587
                if aid == "" {
2✔
588
                        continue
×
589
                }
590
                wg.Add(1)
2✔
591
                go func(aid string) {
4✔
592
                        defer wg.Done()
2✔
593
                        if err := s.attachments.RemoveRef(ctx, aid, msgID); err != nil {
2✔
594
                                slog.Warn("attachment remove ref failed", "attID", aid, "msgID", msgID, "error", err)
×
595
                        }
×
596
                }(aid)
597
        }
598
        wg.Wait()
2✔
599
}
600

601
// flagNonMemberMentions inspects the message body for @[id|name] markers
602
// and, for each mentioned user who is NOT a member of the channel, posts
603
// a system message in the channel announcing it so an admin can decide
604
// to invite them. No-op when nothing matches. Errors are swallowed —
605
// the user's send already succeeded and a missing audit message must
606
// not be allowed to cascade into a failed publish.
607
//
608
// We do per-mention GetMembership rather than scanning the whole channel
609
// (ListMembers): a typical message has 0–2 mentions, so 0–2 point reads
610
// is cheaper than one channel-wide scan. The notifier already pays for
611
// the channel-wide load on a different code path; reusing it would
612
// require cross-cutting plumbing not worth the few RCUs saved.
613
func (s *MessageService) flagNonMemberMentions(ctx context.Context, msg *model.Message) {
7✔
614
        if s.memberships == nil {
7✔
NEW
615
                return
×
NEW
616
        }
×
617
        mentions := ParseMentions(msg.Body)
7✔
618
        if len(mentions.Users) == 0 {
12✔
619
                return
5✔
620
        }
5✔
621
        for _, mention := range mentions.Users {
4✔
622
                if _, err := s.memberships.GetMembership(ctx, msg.ParentID, mention.UserID); err == nil {
3✔
623
                        continue
1✔
624
                }
625
                body := "@" + mention.DisplayName + " was mentioned but isn't a member of this channel — an admin can invite them via the channel members list."
1✔
626
                s.postSystemMessage(ctx, msg.ParentID, body)
1✔
627
        }
628
}
629

630
// postSystemMessage persists a synthetic message attributed to "system" and
631
// publishes a message.new event so connected clients render it inline.
632
// Used for join/leave/audit-style notices and the non-member-mention flag.
633
func (s *MessageService) postSystemMessage(ctx context.Context, channelID, body string) {
1✔
634
        sysMsg := &model.Message{
1✔
635
                ID:        store.NewID(),
1✔
636
                ParentID:  channelID,
1✔
637
                AuthorID:  "system",
1✔
638
                Body:      body,
1✔
639
                System:    true,
1✔
640
                CreatedAt: time.Now(),
1✔
641
        }
1✔
642
        if err := s.messages.CreateMessage(ctx, sysMsg); err != nil {
1✔
NEW
643
                return
×
NEW
644
        }
×
645
        events.Publish(ctx, s.publisher, pubsub.ChannelName(channelID), events.EventMessageNew, sysMsg)
1✔
646
}
647

648
// publishEvent sends a real-time event to the appropriate pub/sub channel.
649
func (s *MessageService) publishEvent(ctx context.Context, parentID, parentType, eventType string, data any) {
26✔
650
        var channel string
26✔
651
        switch parentType {
26✔
652
        case ParentChannel:
21✔
653
                channel = pubsub.ChannelName(parentID)
21✔
654
        case ParentConversation:
4✔
655
                channel = pubsub.ConversationName(parentID)
4✔
656
        default:
1✔
657
                return
1✔
658
        }
659
        events.Publish(ctx, s.publisher, channel, eventType, data)
25✔
660
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc