mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -219,4 +219,3 @@ func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) {
|
||||
}
|
||||
h.pubsub.PublishLow(ChannelTopic(channelID), msg, excludeUserID)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user