8696d85514
D-070: у групповой беседы не было своей иконки — показывались имя и первая
буква аватара, как у 1:1 без аватара. Колонки под файл у канала не было.
Миграция 00024 добавляет `channels.icon_file_id` (REFERENCES files ON DELETE
SET NULL): NULL у 1:1 и комнат сервера, файл — у группы. Store читает и пишет
поле через `Channel`/`UpdateChannelParams` (`IconFileID`/`ClearIcon`), отдаёт
его в `ListDMChannels` и в READY, а уборка сирот больше не считает иконку
беседы мусором. Когда в группе остаётся два участника, беседа снова обычная
личная — иконка сбрасывается вместе с именем и владельцем.
Ручки `POST/DELETE /channels/{id}/icon` принимают multipart (назначение файла
`dm_icon`, аватарный лимит, проверка `image/`) и доступны только владельцу
беседы: участнику-не-владельцу 403 `perm.denied`, посторонним 404 (существование
чужой беседы не подтверждаем), у 1:1 своей иконки нет — 422 `dm.not_group`.
Файл иконки отдаётся только участникам беседы. После изменения участникам
уходит `DM_CHANNEL_UPDATE`, поэтому иконка меняется без перезагрузки
(AGENT.md 7.7, 7.8, 8.3, 11.6; D-042, D-070).
189 lines
7.4 KiB
Go
189 lines
7.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"time"
|
|
)
|
|
|
|
// Групповые личные беседы (AGENT.md 7.8) живут в тех же таблицах, что и 1:1:
|
|
// channels.type = 'dm' + dm_participants. Отличие только в числе участников и
|
|
// владельце (channels.dm_owner_id): он добавляет/удаляет участников и
|
|
// переименовывает беседу. Беседа с двумя участниками снова считается обычной
|
|
// личной, с одним — удаляется (см. RemoveDMParticipant).
|
|
|
|
// CreateGroupDMChannel создаёт групповую беседу: владелец плюс приглашённые.
|
|
func (s *Store) CreateGroupDMChannel(ctx context.Context, ownerID uint64, name string, participantIDs []uint64) (*Channel, error) {
|
|
channelID := s.NextID()
|
|
members := make([]uint64, 0, len(participantIDs)+1)
|
|
members = append(members, ownerID)
|
|
for _, participantID := range participantIDs {
|
|
if participantID != ownerID {
|
|
members = append(members, participantID)
|
|
}
|
|
}
|
|
if err := s.InTx(ctx, func(tx *sql.Tx) error {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO channels (id, guild_id, type, name, position, dm_owner_id, created_at)
|
|
VALUES (?, NULL, 'dm', ?, 0, ?, ?)`,
|
|
int64(channelID), name, int64(ownerID), s.Now()); err != nil {
|
|
return err
|
|
}
|
|
for _, userID := range members {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO dm_participants (channel_id, user_id, joined_at) VALUES (?, ?, ?)`,
|
|
int64(channelID), int64(userID), s.Now()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
return s.GetChannel(ctx, channelID)
|
|
}
|
|
|
|
// AddDMParticipant добавляет участника в беседу (повторный вызов — не ошибка).
|
|
func (s *Store) AddDMParticipant(ctx context.Context, channelID, userID uint64) error {
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO dm_participants (channel_id, user_id, joined_at) VALUES (?, ?, ?)
|
|
ON CONFLICT (channel_id, user_id) DO NOTHING`,
|
|
int64(channelID), int64(userID), s.Now())
|
|
return err
|
|
}
|
|
|
|
// RemoveDMParticipant убирает участника. Когда участников остаётся двое,
|
|
// беседа снова обычная личная (имя и владелец сбрасываются), а когда один —
|
|
// канал удаляется вместе с сообщениями (FK ON DELETE CASCADE) и файлами
|
|
// вложений: переписка с самим собой смысла не имеет.
|
|
func (s *Store) RemoveDMParticipant(ctx context.Context, channelID, userID uint64) error {
|
|
return s.InTx(ctx, func(tx *sql.Tx) error {
|
|
result, err := tx.ExecContext(ctx,
|
|
`DELETE FROM dm_participants WHERE channel_id = ? AND user_id = ?`,
|
|
int64(channelID), int64(userID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
var remaining int
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM dm_participants WHERE channel_id = ?`, int64(channelID)).Scan(&remaining); err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case remaining >= 3:
|
|
// Владелец ушёл — передаём беседу самому «старому» участнику.
|
|
var ownerID sql.NullInt64
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT dm_owner_id FROM channels WHERE id = ?`, int64(channelID)).Scan(&ownerID); err != nil {
|
|
return err
|
|
}
|
|
if ownerID.Valid && uint64(ownerID.Int64) == userID {
|
|
var nextOwner uint64
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT user_id FROM dm_participants WHERE channel_id = ? ORDER BY joined_at, user_id LIMIT 1`,
|
|
int64(channelID)).Scan(&nextOwner); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE channels SET dm_owner_id = ? WHERE id = ?`, int64(nextOwner), int64(channelID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case remaining == 2:
|
|
// Беседа снова обычная личная: имя, владелец и иконка группы
|
|
// сбрасываются (AGENT.md 7.8, D-070). Файл иконки после этого
|
|
// становится сиротой и удаляется уборкой (AGENT.md 7.7).
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE channels SET name = '', dm_owner_id = NULL, icon_file_id = NULL WHERE id = ?`,
|
|
int64(channelID)); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, int64(channelID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// RenameDMChannel задаёт имя групповой беседы.
|
|
func (s *Store) RenameDMChannel(ctx context.Context, channelID uint64, name string) error {
|
|
_, err := s.writer.ExecContext(ctx,
|
|
`UPDATE channels SET name = ? WHERE id = ? AND type = 'dm'`, name, int64(channelID))
|
|
return err
|
|
}
|
|
|
|
// DMParticipantCount считает участников беседы.
|
|
func (s *Store) DMParticipantCount(ctx context.Context, channelID uint64) (int, error) {
|
|
var count int
|
|
err := s.reader.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM dm_participants WHERE channel_id = ?`, int64(channelID)).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// DMParticipant — участник беседы для интерфейса (список участников группы).
|
|
type DMParticipant struct {
|
|
UserID uint64
|
|
Username string
|
|
DisplayName string
|
|
AvatarFileID *uint64
|
|
Status string
|
|
LastSeenAt *time.Time
|
|
IsOwner bool
|
|
IsCurrentUser bool
|
|
}
|
|
|
|
// DMParticipantProfiles возвращает участников беседы с профилями.
|
|
func (s *Store) DMParticipantProfiles(ctx context.Context, channelID, currentUserID uint64) ([]DMParticipant, error) {
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
SELECT u.id, u.username, u.display_name, u.avatar_file_id, u.status, u.last_seen_at,
|
|
c.dm_owner_id = u.id
|
|
FROM dm_participants p
|
|
JOIN users u ON u.id = p.user_id
|
|
JOIN channels c ON c.id = p.channel_id
|
|
WHERE p.channel_id = ? AND u.deleted_at IS NULL
|
|
ORDER BY p.joined_at, u.id`, int64(channelID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
participants := make([]DMParticipant, 0, 4)
|
|
for rows.Next() {
|
|
var (
|
|
participant DMParticipant
|
|
avatarID sql.NullInt64
|
|
lastSeenAt sql.NullString
|
|
isOwner int
|
|
)
|
|
if err := rows.Scan(&participant.UserID, &participant.Username, &participant.DisplayName,
|
|
&avatarID, &participant.Status, &lastSeenAt, &isOwner); err != nil {
|
|
return nil, err
|
|
}
|
|
participant.AvatarFileID = optionalID(avatarID)
|
|
if lastSeenAt.Valid {
|
|
value := parseTimestamp(lastSeenAt.String)
|
|
participant.LastSeenAt = &value
|
|
}
|
|
participant.IsOwner = isOwner == 1
|
|
participant.IsCurrentUser = participant.UserID == currentUserID
|
|
participants = append(participants, participant)
|
|
}
|
|
return participants, rows.Err()
|
|
}
|
|
|
|
// GroupDMOwnerID возвращает владельца групповой беседы (nil — обычная 1:1).
|
|
func (s *Store) GroupDMOwnerID(ctx context.Context, channelID uint64) (*uint64, error) {
|
|
var ownerID sql.NullInt64
|
|
err := s.reader.QueryRowContext(ctx,
|
|
`SELECT dm_owner_id FROM channels WHERE id = ? AND type = 'dm'`, int64(channelID)).Scan(&ownerID)
|
|
if err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
return optionalID(ownerID), nil
|
|
}
|