feat(dm): групповые личные беседы (AGENT.md 7.8, Фаза 7)
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
This commit is contained in:
@@ -186,6 +186,8 @@ func (s *Store) FindDMChannel(ctx context.Context, firstID, secondID uint64) (*C
|
||||
}
|
||||
|
||||
// DMChannelSummary — личная беседа со собеседником и последним сообщением.
|
||||
// Для групповой беседы (AGENT.md 7.8) вместо собеседника заполняются Name,
|
||||
// MemberCount и Participants, а RecipientID остаётся нулевым.
|
||||
type DMChannelSummary struct {
|
||||
Channel Channel
|
||||
RecipientID uint64
|
||||
@@ -198,10 +200,31 @@ type DMChannelSummary struct {
|
||||
LastMessageID uint64
|
||||
LastMessageAt *time.Time
|
||||
LastAuthorID *uint64
|
||||
// IsGroup — участников больше двух; GroupName — имя беседы.
|
||||
IsGroup bool
|
||||
GroupName string
|
||||
MemberCount int
|
||||
Participants []DMParticipant
|
||||
OwnerID *uint64
|
||||
}
|
||||
|
||||
// ListDMChannels возвращает личные беседы пользователя.
|
||||
// ListDMChannels возвращает личные беседы пользователя: и 1:1, и групповые
|
||||
// (AGENT.md 7.8). Беседа считается групповой, если в ней больше двух
|
||||
// участников; с двумя — обычная личная, с одним — не показывается вовсе.
|
||||
func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||
channels, err := s.listDirectChannels(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := s.listGroupChannels(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(channels, groups...), nil
|
||||
}
|
||||
|
||||
// listDirectChannels возвращает беседы ровно с двумя участниками.
|
||||
func (s *Store) listDirectChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT c.id, c.created_at, u.id, u.display_name, u.username, u.avatar_file_id, u.status,
|
||||
u.last_seen_at, u.timezone,
|
||||
@@ -213,6 +236,7 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS
|
||||
JOIN dm_participants other ON other.channel_id = c.id AND other.user_id <> p.user_id
|
||||
JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL
|
||||
WHERE p.user_id = ?
|
||||
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) = 2
|
||||
ORDER BY c.id DESC`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -255,6 +279,69 @@ func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelS
|
||||
return channels, rows.Err()
|
||||
}
|
||||
|
||||
// listGroupChannels возвращает групповые беседы: имя, число участников и
|
||||
// последнее сообщение. Участники подтягиваются отдельным запросом — групп у
|
||||
// пользователя немного, а список нужен интерфейсу для карточки беседы.
|
||||
func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT c.id, c.name, c.dm_owner_id, c.created_at,
|
||||
(SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id),
|
||||
(SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||
(SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
||||
(SELECT m.author_id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1)
|
||||
FROM dm_participants p
|
||||
JOIN channels c ON c.id = p.channel_id AND c.type = 'dm'
|
||||
WHERE p.user_id = ?
|
||||
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) > 2
|
||||
ORDER BY c.id DESC`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := make([]DMChannelSummary, 0, 4)
|
||||
for rows.Next() {
|
||||
var (
|
||||
summary DMChannelSummary
|
||||
ownerID sql.NullInt64
|
||||
createdAt string
|
||||
lastMessageID sql.NullInt64
|
||||
lastMessageAt sql.NullString
|
||||
lastAuthorID sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt,
|
||||
&summary.MemberCount, &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.IsGroup = true
|
||||
summary.OwnerID = optionalID(ownerID)
|
||||
summary.Channel.Type = ChannelDM
|
||||
summary.Channel.Name = summary.GroupName
|
||||
summary.Channel.DMOwnerID = summary.OwnerID
|
||||
summary.Channel.CreatedAt = parseTimestamp(createdAt)
|
||||
if lastMessageID.Valid {
|
||||
summary.LastMessageID = uint64(lastMessageID.Int64)
|
||||
}
|
||||
if lastMessageAt.Valid {
|
||||
value := parseTimestamp(lastMessageAt.String)
|
||||
summary.LastMessageAt = &value
|
||||
}
|
||||
summary.LastAuthorID = optionalID(lastAuthorID)
|
||||
groups = append(groups, summary)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range groups {
|
||||
participants, err := s.DMParticipantProfiles(ctx, groups[index].Channel.ID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups[index].Participants = participants
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// TouchLastSeen обновляет время последней активности пользователя.
|
||||
func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx,
|
||||
|
||||
Reference in New Issue
Block a user