feat(server,client): announcement channels (D1, closes A-2026-07-01)

Make 'announcement' a real channel type, resolving the contradiction where
it was documented and offered by the admin API but hard-rejected by the
migration-013 DB triggers.

Model: announcement channels are readable like text channels (same
READ_MESSAGES visibility), but posting is restricted to users with
MANAGE_MESSAGES — no new permission bit, migration, or client permission
plumbing needed.

Server:
- migrations/016: recreate the channel-type triggers to allow
  text/voice/announcement/dm.
- service/message.go: checkSendPermission now takes the channel type and
  rejects posts to announcement channels from users lacking MANAGE_MESSAGES
  (SendMessage + CanPost paths). Added a service test.
- Unread counts: ready-payload builder (ws/serve.go) and
  GetChannelUnreadCounts (db) now include announcement channels alongside
  text, so they track unread/last-message like text channels.

Client:
- ChannelSidebar renders announcement channels with a megaphone icon
  (added to the icon set) instead of the '#' text prefix; they otherwise
  behave like text channels (already typed in ChannelType).

Specs + trackers (api.md, protocol.md, schema.md incl. migration 016,
architecture/data-model.md, audit A-2026-07-01, decisions D1) updated.

Verified: go build ./...; go test ./service ./db ./ws ./api ./admin;
sqlc-verify; client tsc + oxlint + prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 16:00:18 +00:00
parent f66d6c5274
commit 071426c0d8
14 changed files with 92 additions and 18 deletions
+1 -1
View File
@@ -444,7 +444,7 @@ func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, erro
FROM channels c
LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = 0
LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ?
WHERE c.type = 'text'
WHERE c.type IN ('text', 'announcement')
GROUP BY c.id`,
userID,
)
@@ -0,0 +1,26 @@
-- Allow the 'announcement' channel type.
--
-- Migration 013 added INSERT/UPDATE triggers restricting channels.type to
-- text/voice/dm, which contradicted the admin UI and specs that already
-- offered 'announcement'. Announcement channels are now a real type: readable
-- like text channels, but posting is restricted to users with MANAGE_MESSAGES
-- (enforced in the service layer). Recreate the triggers to include it.
DROP TRIGGER IF EXISTS trg_channels_type_check_insert;
DROP TRIGGER IF EXISTS trg_channels_type_check_update;
CREATE TRIGGER IF NOT EXISTS trg_channels_type_check_insert
BEFORE INSERT ON channels
FOR EACH ROW
WHEN NEW.type NOT IN ('text', 'voice', 'announcement', 'dm')
BEGIN
SELECT RAISE(ABORT, 'invalid channel type: must be text, voice, announcement, or dm');
END;
CREATE TRIGGER IF NOT EXISTS trg_channels_type_check_update
BEFORE UPDATE OF type ON channels
FOR EACH ROW
WHEN NEW.type NOT IN ('text', 'voice', 'announcement', 'dm')
BEGIN
SELECT RAISE(ABORT, 'invalid channel type: must be text, voice, announcement, or dm');
END;
+13 -4
View File
@@ -148,7 +148,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) (
isDM := ch.Type == "dm"
// Permission check.
if err := s.checkSendPermission(p.UserID, p.ChannelID, isDM); err != nil {
if err := s.checkSendPermission(p.UserID, p.ChannelID, ch.Type); err != nil {
return nil, err
}
@@ -687,11 +687,15 @@ func (s *MessageService) CanPost(userID, channelID int64) error {
if err != nil || ch == nil {
return fmt.Errorf("%w: channel not found", ErrNotFound)
}
return s.checkSendPermission(userID, channelID, ch.Type == "dm")
return s.checkSendPermission(userID, channelID, ch.Type)
}
// checkSendPermission validates send permission for DM and non-DM channels.
func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error {
// checkSendPermission validates send permission for a channel of the given
// type. Announcement channels are readable by anyone with READ_MESSAGES but
// only postable by users with MANAGE_MESSAGES (posting is restricted to
// moderators/admins); all other non-DM channels require SEND_MESSAGES.
func (s *MessageService) checkSendPermission(userID, channelID int64, chanType string) error {
isDM := chanType == "dm"
if isDM {
ok, err := s.st.IsDMParticipant(userID, channelID)
if err != nil {
@@ -715,6 +719,11 @@ func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool)
if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages|permissions.SendMessages) {
return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden)
}
// Announcement channels: posting is restricted to users who can manage
// messages, even though everyone with READ_MESSAGES can view them.
if chanType == "announcement" && !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) {
return fmt.Errorf("%w: announcement channels require MANAGE_MESSAGES to post", ErrForbidden)
}
return nil
}
+31
View File
@@ -109,6 +109,37 @@ func TestCanPost_ChannelPermissionRequired(t *testing.T) {
}
}
// TestCanPost_AnnouncementRequiresManageMessages: announcement channels are
// postable only by users with MANAGE_MESSAGES, even when they hold
// READ|SEND. A plain member is refused; a moderator with MANAGE_MESSAGES posts.
func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) {
ms := store.NewMemStore()
ms.SeedRole(&db.Role{
ID: permissions.MemberRoleID, Name: "member",
Permissions: permissions.ReadMessages | permissions.SendMessages, Position: 1,
})
ms.SeedRole(&db.Role{
ID: permissions.ModeratorRoleID, Name: "moderator",
Permissions: permissions.ReadMessages | permissions.SendMessages | permissions.ManageMessages, Position: 60,
})
ms.SeedUserRole(1, permissions.MemberRoleID)
ms.SeedUserRole(2, permissions.ModeratorRoleID)
ms.SeedUser(&db.User{ID: 1, Username: "alice"})
ms.SeedUser(&db.User{ID: 2, Username: "mod"})
ms.SeedChannel(&db.Channel{ID: 20, Name: "announcements", Type: "announcement"})
checker := permissions.NewChecker(ms)
svc := NewMessageService(ms, NewPermissionService(ms, checker), nil)
// Member has READ|SEND but not MANAGE_MESSAGES → refused in an announcement channel.
if err := svc.CanPost(1, 20); !errors.Is(err, ErrForbidden) {
t.Fatalf("member without MANAGE_MESSAGES must be refused in announcement channel: got %v", err)
}
// Moderator with MANAGE_MESSAGES → allowed.
if err := svc.CanPost(2, 20); err != nil {
t.Fatalf("moderator with MANAGE_MESSAGES must post in announcement channel: got %v", err)
}
}
// TestSendMessage_AttachmentOwnershipAtomic locks the W1-3 semantics: the
// link UPDATE itself enforces ownership, so a foreign, already-linked, or
// nonexistent attachment is skipped (never linked) while the message still
-1
View File
@@ -219,4 +219,3 @@ func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) {
}
h.pubsub.PublishLow(ChannelTopic(channelID), msg, excludeUserID)
}
+1 -1
View File
@@ -633,7 +633,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte,
"category": visibleChannels[i].Category,
"position": visibleChannels[i].Position,
}
if visibleChannels[i].Type == "text" {
if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" {
if u, ok := unreadMap[visibleChannels[i].ID]; ok {
entry["unread_count"] = u.UnreadCount
entry["last_message_id"] = u.LastMessageID