Files
grendervill 566e1cf981 feat(invites): страница приглашения со splash и переопределением
- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
  сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
  создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
  карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
  возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
  присоединение, недействительная ссылка)
2026-09-21 19:25:06 +03:00

173 lines
5.4 KiB
Go

package store
import (
"context"
"database/sql"
"time"
)
// Invite — приглашение на сервер (AGENT.md 7.9).
type Invite struct {
Code string
GuildID uint64
ChannelID *uint64
CreatorID *uint64
MaxUses int
Uses int
MaxAgeSec int
ExpiresAt *time.Time
CreatedAt time.Time
// BackgroundFileID — переопределение splash для этого приглашения
// (AGENT.md 7.9): страница приглашения показывает свою картинку.
BackgroundFileID *uint64
}
// CreateInviteParams — параметры приглашения.
type CreateInviteParams struct {
Code string
GuildID uint64
ChannelID *uint64
CreatorID uint64
MaxUses int
MaxAgeSec int
}
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
max_age_sec, expires_at, created_at, background_file_id`
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
var expiresAt any
if params.MaxAgeSec > 0 {
expiresAt = s.Timestamp(time.Now().UTC().Add(time.Duration(params.MaxAgeSec) * time.Second))
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO invites (code, guild_id, channel_id, creator_id, max_uses, uses, max_age_sec, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
params.Code, int64(params.GuildID), nullableID(params.ChannelID), int64(params.CreatorID),
params.MaxUses, params.MaxAgeSec, expiresAt, s.Now())
if err != nil {
return nil, mapError(err)
}
return s.GetInvite(ctx, params.Code)
}
func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) {
row := s.reader.QueryRowContext(ctx, `SELECT `+inviteColumns+` FROM invites WHERE code = ?`, code)
return scanInvite(row)
}
// SetInviteBackground задаёт или снимает переопределение splash приглашения.
func (s *Store) SetInviteBackground(ctx context.Context, code string, fileID *uint64) (*Invite, error) {
result, err := s.writer.ExecContext(ctx,
`UPDATE invites SET background_file_id = ? WHERE code = ?`, nullableID(fileID), code)
if err != nil {
return nil, mapError(err)
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetInvite(ctx, code)
}
// ListGuildInvites возвращает приглашения сервера (для панели управления).
func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+inviteColumns+` FROM invites WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?`,
int64(guildID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
invites := make([]Invite, 0, limit)
for rows.Next() {
invite, err := scanInvite(rows)
if err != nil {
return nil, err
}
invites = append(invites, *invite)
}
return invites, rows.Err()
}
// UseInvite атомарно увеличивает счётчик использований, проверяя лимит и срок.
// Возвращает ErrNotFound, если приглашение исчерпано или истекло (AGENT.md 7.9).
func (s *Store) UseInvite(ctx context.Context, code string) (*Invite, error) {
result, err := s.writer.ExecContext(ctx, `
UPDATE invites SET uses = uses + 1
WHERE code = ?
AND (max_uses = 0 OR uses < max_uses)
AND (expires_at IS NULL OR expires_at > ?)`, code, s.Now())
if err != nil {
return nil, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetInvite(ctx, code)
}
func (s *Store) DeleteInvite(ctx context.Context, code string) error {
result, err := s.writer.ExecContext(ctx, `DELETE FROM invites WHERE code = ?`, code)
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// ListPublicGuilds возвращает публичные серверы для каталога (AGENT.md 7.20).
func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+guildColumns+` FROM guilds
WHERE is_discoverable = 1 ORDER BY id LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
guilds := make([]Guild, 0, limit)
for rows.Next() {
guild, err := scanGuild(rows)
if err != nil {
return nil, err
}
guilds = append(guilds, *guild)
}
return guilds, rows.Err()
}
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
var (
invite Invite
channelID sql.NullInt64
creatorID sql.NullInt64
expiresAt sql.NullString
createdAt string
background sql.NullInt64
)
err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses,
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt, &background)
if err != nil {
return nil, mapError(err)
}
invite.ChannelID = optionalID(channelID)
invite.CreatorID = optionalID(creatorID)
invite.BackgroundFileID = optionalID(background)
if expiresAt.Valid {
value := parseTimestamp(expiresAt.String)
invite.ExpiresAt = &value
}
invite.CreatedAt = parseTimestamp(createdAt)
return &invite, nil
}