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, гость,
  присоединение, недействительная ссылка)
This commit is contained in:
2026-09-21 19:25:06 +03:00
parent ed98c78c9e
commit 566e1cf981
14 changed files with 701 additions and 24 deletions
+25 -7
View File
@@ -17,6 +17,9 @@ type Invite struct {
MaxAgeSec int
ExpiresAt *time.Time
CreatedAt time.Time
// BackgroundFileID — переопределение splash для этого приглашения
// (AGENT.md 7.9): страница приглашения показывает свою картинку.
BackgroundFileID *uint64
}
// CreateInviteParams — параметры приглашения.
@@ -30,7 +33,7 @@ type CreateInviteParams struct {
}
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
max_age_sec, expires_at, created_at`
max_age_sec, expires_at, created_at, background_file_id`
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
@@ -54,6 +57,19 @@ func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) {
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 {
@@ -132,19 +148,21 @@ func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
var (
invite Invite
channelID sql.NullInt64
creatorID sql.NullInt64
expiresAt sql.NullString
createdAt string
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)
&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