feat(guilds): встроенные шаблоны серверов
- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли, категории, текстовые и голосовые комнаты без сообщений - POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит фиксирует выбранный шаблон - GET /guild-templates отдаёт каталог с предпросмотром структуры - клиент: выбор шаблона в окне создания сервера - тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
This commit is contained in:
@@ -186,6 +186,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
}, func(ctx context.Context, input *struct {
|
}, func(ctx context.Context, input *struct {
|
||||||
Body struct {
|
Body struct {
|
||||||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||||||
|
// TemplateID — встроенный шаблон структуры (AGENT.md 7.3);
|
||||||
|
// пусто — «Пустой».
|
||||||
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) (*guildOutput, error) {
|
) (*guildOutput, error) {
|
||||||
@@ -197,13 +200,32 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
||||||
}
|
}
|
||||||
guild, err := s.createGuild(ctx, user, name)
|
guild, err := s.createGuild(ctx, user, name, input.Body.TemplateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.guildOutput(ctx, user, *guild)
|
return s.guildOutput(ctx, user, *guild)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "listGuildTemplates",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/guild-templates",
|
||||||
|
Summary: "Встроенные шаблоны серверов",
|
||||||
|
Tags: []string{"Guilds"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, _ *struct{}) (*guildTemplateListOutput, error) {
|
||||||
|
if _, _, err := requireUser(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output := &guildTemplateListOutput{}
|
||||||
|
output.Body.Templates = make([]guildTemplatePayload, 0, len(guildTemplates))
|
||||||
|
for _, template := range guildTemplates {
|
||||||
|
output.Body.Templates = append(output.Body.Templates, guildTemplateFromCatalog(template))
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
huma.Register(api, huma.Operation{
|
||||||
OperationID: "getGuild",
|
OperationID: "getGuild",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -1518,7 +1540,11 @@ func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
|
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
|
||||||
func (s *Server) createGuild(ctx context.Context, owner *store.User, name string) (*store.Guild, error) {
|
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
|
||||||
|
template, known := findGuildTemplate(templateID)
|
||||||
|
if !known {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||||
|
}
|
||||||
settings, err := s.store.InstanceSettings(ctx)
|
settings, err := s.store.InstanceSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
@@ -1552,17 +1578,16 @@ func (s *Server) createGuild(ctx context.Context, owner *store.User, name string
|
|||||||
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
|
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
// Структура комнат и ролей — из выбранного шаблона (AGENT.md 7.3).
|
||||||
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
|
if err := s.applyGuildTemplate(ctx, guild, template); err != nil {
|
||||||
}); err != nil {
|
return nil, err
|
||||||
return nil, humaError(err)
|
|
||||||
}
|
}
|
||||||
if guild.IsMain {
|
if guild.IsMain {
|
||||||
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "")
|
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID)
|
||||||
if bypassed {
|
if bypassed {
|
||||||
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
|
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"glchat/internal/permissions"
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный
|
||||||
|
// шаблон — структура комнат и трёх ролей без сообщений. Свои шаблоны — после
|
||||||
|
// MVP, поэтому каталог статический.
|
||||||
|
|
||||||
|
// guildTemplateRole — роль, которую создаёт шаблон.
|
||||||
|
type guildTemplateRole struct {
|
||||||
|
Name string
|
||||||
|
Permissions []permissions.Permission
|
||||||
|
Position int
|
||||||
|
// Hoist — показывать участников с этой ролью отдельной группой.
|
||||||
|
Hoist bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри
|
||||||
|
// которой она создаётся (пусто — верхний уровень).
|
||||||
|
type guildTemplateChannel struct {
|
||||||
|
Name string
|
||||||
|
Type store.ChannelType
|
||||||
|
Category string
|
||||||
|
}
|
||||||
|
|
||||||
|
// guildTemplate — встроенный шаблон сервера.
|
||||||
|
type guildTemplate struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Roles []guildTemplateRole
|
||||||
|
Channels []guildTemplateChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
// guildTemplates — каталог шаблонов; первый — пустой сервер (поведение по
|
||||||
|
// умолчанию: одна общая текстовая комната).
|
||||||
|
var guildTemplates = []guildTemplate{
|
||||||
|
{
|
||||||
|
ID: "empty",
|
||||||
|
Name: "Пустой",
|
||||||
|
Description: "Одна общая текстовая комната и роли по умолчанию.",
|
||||||
|
Channels: []guildTemplateChannel{
|
||||||
|
{Name: "общий", Type: store.ChannelText},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "community",
|
||||||
|
Name: "Сообщество",
|
||||||
|
Description: "Информация и общение: правила, объявления, флудилка и голосовая комната.",
|
||||||
|
Roles: []guildTemplateRole{
|
||||||
|
{
|
||||||
|
Name: "Модератор",
|
||||||
|
Permissions: []permissions.Permission{
|
||||||
|
permissions.KickMembers, permissions.TimeoutMembers, permissions.ManageMessages,
|
||||||
|
permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers,
|
||||||
|
permissions.MoveMembers,
|
||||||
|
},
|
||||||
|
Position: 50,
|
||||||
|
Hoist: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Channels: []guildTemplateChannel{
|
||||||
|
{Name: "Информация", Type: store.ChannelCategory},
|
||||||
|
{Name: "правила", Type: store.ChannelText, Category: "Информация"},
|
||||||
|
{Name: "объявления", Type: store.ChannelText, Category: "Информация"},
|
||||||
|
{Name: "Общение", Type: store.ChannelCategory},
|
||||||
|
{Name: "общий", Type: store.ChannelText, Category: "Общение"},
|
||||||
|
{Name: "флудилка", Type: store.ChannelText, Category: "Общение"},
|
||||||
|
{Name: "Голосовой", Type: store.ChannelVoice, Category: "Общение"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "gaming",
|
||||||
|
Name: "Игровой",
|
||||||
|
Description: "Текстовые комнаты для сбора и две голосовые под команды.",
|
||||||
|
Roles: []guildTemplateRole{
|
||||||
|
{
|
||||||
|
Name: "Игрок",
|
||||||
|
Permissions: []permissions.Permission{
|
||||||
|
permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard,
|
||||||
|
permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks,
|
||||||
|
},
|
||||||
|
Position: 50,
|
||||||
|
Hoist: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Channels: []guildTemplateChannel{
|
||||||
|
{Name: "Текстовые", Type: store.ChannelCategory},
|
||||||
|
{Name: "общий", Type: store.ChannelText, Category: "Текстовые"},
|
||||||
|
{Name: "поиск-игр", Type: store.ChannelText, Category: "Текстовые"},
|
||||||
|
{Name: "Голосовые", Type: store.ChannelCategory},
|
||||||
|
{Name: "Игровая 1", Type: store.ChannelVoice, Category: "Голосовые"},
|
||||||
|
{Name: "Игровая 2", Type: store.ChannelVoice, Category: "Голосовые"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionSet собирает маску прав из списка.
|
||||||
|
func permissionSet(list []permissions.Permission) permissions.Permission {
|
||||||
|
var result permissions.Permission
|
||||||
|
for _, permission := range list {
|
||||||
|
result |= permission
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// findGuildTemplate ищет шаблон по идентификатору; пустой id — «Пустой».
|
||||||
|
func findGuildTemplate(id string) (guildTemplate, bool) {
|
||||||
|
if id == "" {
|
||||||
|
id = guildTemplates[0].ID
|
||||||
|
}
|
||||||
|
for _, template := range guildTemplates {
|
||||||
|
if template.ID == id {
|
||||||
|
return template, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return guildTemplate{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyGuildTemplate создаёт роли и комнаты шаблона. Администратор и
|
||||||
|
// «Пользователь» уже созданы SeedGuildDefaults, поэтому шаблон добавляет
|
||||||
|
// только свои роли (AGENT.md 6.3, 7.3).
|
||||||
|
func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, template guildTemplate) error {
|
||||||
|
existing, err := s.store.ListGuildRoles(ctx, guild.ID)
|
||||||
|
if err != nil {
|
||||||
|
return humaError(err)
|
||||||
|
}
|
||||||
|
known := make(map[string]bool, len(existing))
|
||||||
|
for _, role := range existing {
|
||||||
|
known[role.Name] = true
|
||||||
|
}
|
||||||
|
for _, role := range template.Roles {
|
||||||
|
if known[role.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateRole(ctx, store.CreateRoleParams{
|
||||||
|
GuildID: guild.ID,
|
||||||
|
Name: role.Name,
|
||||||
|
Permissions: uint64(permissionSet(role.Permissions)),
|
||||||
|
Position: role.Position,
|
||||||
|
Hoist: role.Hoist,
|
||||||
|
Mentionable: true,
|
||||||
|
}); err != nil {
|
||||||
|
return humaError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Категории создаются до комнат: комнаты ссылаются на них через parent_id.
|
||||||
|
categories := map[string]uint64{}
|
||||||
|
position := 0
|
||||||
|
for _, channel := range template.Channels {
|
||||||
|
parentID := uint64(0)
|
||||||
|
if channel.Category != "" {
|
||||||
|
id, ok := categories[channel.Category]
|
||||||
|
if !ok {
|
||||||
|
created, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
||||||
|
GuildID: &guild.ID, Type: store.ChannelCategory,
|
||||||
|
Name: channel.Category, Position: position,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return humaError(err)
|
||||||
|
}
|
||||||
|
categories[channel.Category] = created.ID
|
||||||
|
position++
|
||||||
|
id = created.ID
|
||||||
|
}
|
||||||
|
parentID = id
|
||||||
|
}
|
||||||
|
params := store.CreateChannelParams{
|
||||||
|
GuildID: &guild.ID,
|
||||||
|
Type: channel.Type,
|
||||||
|
Name: channel.Name,
|
||||||
|
Position: position,
|
||||||
|
}
|
||||||
|
if parentID != 0 && channel.Type != store.ChannelCategory {
|
||||||
|
params.ParentID = &parentID
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateChannel(ctx, params); err != nil {
|
||||||
|
return humaError(err)
|
||||||
|
}
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guildTemplatePayload — шаблон для клиента: предпросмотр структуры.
|
||||||
|
type guildTemplatePayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
TextCount int `json:"text_channels"`
|
||||||
|
VoiceCount int `json:"voice_channels"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type guildTemplateListOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Templates []guildTemplatePayload `json:"templates"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guildTemplateFromCatalog собирает описание шаблона для клиента.
|
||||||
|
func guildTemplateFromCatalog(template guildTemplate) guildTemplatePayload {
|
||||||
|
payload := guildTemplatePayload{
|
||||||
|
ID: template.ID,
|
||||||
|
Name: template.Name,
|
||||||
|
Description: template.Description,
|
||||||
|
Roles: []string{"Администратор", "Пользователь"},
|
||||||
|
Categories: []string{},
|
||||||
|
}
|
||||||
|
for _, role := range template.Roles {
|
||||||
|
payload.Roles = append(payload.Roles, role.Name)
|
||||||
|
}
|
||||||
|
categories := map[string]bool{}
|
||||||
|
for _, channel := range template.Channels {
|
||||||
|
switch channel.Type {
|
||||||
|
case store.ChannelText:
|
||||||
|
payload.TextCount++
|
||||||
|
case store.ChannelVoice:
|
||||||
|
payload.VoiceCount++
|
||||||
|
case store.ChannelCategory:
|
||||||
|
if !categories[channel.Name] {
|
||||||
|
categories[channel.Name] = true
|
||||||
|
payload.Categories = append(payload.Categories, channel.Name)
|
||||||
|
}
|
||||||
|
case store.ChannelDM:
|
||||||
|
// Личных бесед в шаблонах нет.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру
|
||||||
|
// комнат и ролей без сообщений, неизвестный шаблон отклоняется.
|
||||||
|
|
||||||
|
func TestGuildTemplatesCatalog(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
user := registerAndLogin(t, srv, "tpl_user", "tpl@example.com")
|
||||||
|
|
||||||
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates", "", user)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("каталог шаблонов = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Templates []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
TextCount int `json:"text_channels"`
|
||||||
|
VoiceCount int `json:"voice_channels"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
} `json:"templates"`
|
||||||
|
}](t, rec)
|
||||||
|
if len(payload.Templates) != 3 {
|
||||||
|
t.Fatalf("шаблонов %d, ожидалось 3", len(payload.Templates))
|
||||||
|
}
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, template := range payload.Templates {
|
||||||
|
found[template.ID] = true
|
||||||
|
if len(template.Roles) < 2 {
|
||||||
|
t.Fatalf("шаблон %s без ролей: %+v", template.ID, template.Roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range []string{"empty", "community", "gaming"} {
|
||||||
|
if !found[id] {
|
||||||
|
t.Fatalf("шаблон %s отсутствует в каталоге", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuildFromTemplateCreatesStructure(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
template string
|
||||||
|
wantRoles []string
|
||||||
|
wantText []string
|
||||||
|
wantVoice []string
|
||||||
|
wantCategory []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
template: "empty",
|
||||||
|
wantRoles: []string{"Администратор", "Пользователь"},
|
||||||
|
wantText: []string{"общий"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
template: "community",
|
||||||
|
wantRoles: []string{"Администратор", "Пользователь", "Модератор"},
|
||||||
|
wantText: []string{"правила", "объявления", "общий", "флудилка"},
|
||||||
|
wantVoice: []string{"Голосовой"},
|
||||||
|
wantCategory: []string{"Информация", "Общение"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
template: "gaming",
|
||||||
|
wantRoles: []string{"Администратор", "Пользователь", "Игрок"},
|
||||||
|
wantText: []string{"общий", "поиск-игр"},
|
||||||
|
wantVoice: []string{"Игровая 1", "Игровая 2"},
|
||||||
|
wantCategory: []string{"Текстовые", "Голосовые"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range cases {
|
||||||
|
t.Run(item.template, func(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
owner := registerAndLogin(t, srv, "tpl_"+item.template, "tpl-"+item.template+"@example.com")
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds",
|
||||||
|
`{"name":"Шаблон","template_id":"`+item.template+`"}`, owner)
|
||||||
|
if created.Code != http.StatusOK {
|
||||||
|
t.Fatalf("создание = %d (%s)", created.Code, created.Body.String())
|
||||||
|
}
|
||||||
|
guildID := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, created).Guild.ID
|
||||||
|
|
||||||
|
roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/roles", "", owner)
|
||||||
|
roleNames := make([]string, 0, 4)
|
||||||
|
for _, role := range decodeResponse[struct {
|
||||||
|
Roles []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"roles"`
|
||||||
|
}](t, roles).Roles {
|
||||||
|
roleNames = append(roleNames, role.Name)
|
||||||
|
}
|
||||||
|
for _, want := range item.wantRoles {
|
||||||
|
if !contains(roleNames, want) {
|
||||||
|
t.Fatalf("роль %q не создана: %v", want, roleNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner)
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
Channels []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ParentID string `json:"parent_id"`
|
||||||
|
} `json:"channels"`
|
||||||
|
}](t, channels)
|
||||||
|
byType := map[string][]string{}
|
||||||
|
for _, channel := range list.Channels {
|
||||||
|
byType[channel.Type] = append(byType[channel.Type], channel.Name)
|
||||||
|
// Комнаты шаблона лежат внутри своих категорий.
|
||||||
|
if len(item.wantCategory) > 0 && channel.Type != "category" && channel.ParentID == "" {
|
||||||
|
t.Fatalf("комната %q не привязана к категории", channel.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range item.wantText {
|
||||||
|
if !contains(byType["text"], want) {
|
||||||
|
t.Fatalf("текстовая комната %q не создана: %v", want, byType["text"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range item.wantVoice {
|
||||||
|
if !contains(byType["voice"], want) {
|
||||||
|
t.Fatalf("голосовая комната %q не создана: %v", want, byType["voice"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range item.wantCategory {
|
||||||
|
if !contains(byType["category"], want) {
|
||||||
|
t.Fatalf("категория %q не создана: %v", want, byType["category"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuildWithUnknownTemplateRejected(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
owner := registerAndLogin(t, srv, "tpl_bad", "tpl-bad@example.com")
|
||||||
|
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds",
|
||||||
|
`{"name":"Странный","template_id":"нет-такого"}`, owner)
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("неизвестный шаблон = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains проверяет наличие строки в списке.
|
||||||
|
func contains(list []string, value string) bool {
|
||||||
|
for _, item := range list {
|
||||||
|
if item == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+25
-2
@@ -17,10 +17,33 @@ export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'c
|
|||||||
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
|
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
|
||||||
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
|
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
|
||||||
|
|
||||||
export async function createGuild(name: string): Promise<GuildSummary> {
|
/** Встроенный шаблон структуры сервера (AGENT.md 7.3). */
|
||||||
|
export interface GuildTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
roles: string[];
|
||||||
|
text_channels: number;
|
||||||
|
voice_channels: number;
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const guildTemplatesQueryKey = ['guild-templates'] as const;
|
||||||
|
|
||||||
|
/** `GET /guild-templates` — встроенные шаблоны серверов. */
|
||||||
|
export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
|
||||||
|
const payload = await request<{ templates: GuildTemplate[] }>(
|
||||||
|
'/guild-templates',
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return payload.templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */
|
||||||
|
export async function createGuild(name: string, templateId = ''): Promise<GuildSummary> {
|
||||||
const payload = await request<{ guild: GuildSummary }>('/guilds', {
|
const payload = await request<{ guild: GuildSummary }>('/guilds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { name },
|
body: templateId === '' ? { name } : { name, template_id: templateId },
|
||||||
});
|
});
|
||||||
return payload.guild;
|
return payload.guild;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,9 @@
|
|||||||
"hint": "You can rename it later in the server settings.",
|
"hint": "You can rename it later in the server settings.",
|
||||||
"submit": "Create",
|
"submit": "Create",
|
||||||
"limit": "You can create at most {{count}} servers on this instance.",
|
"limit": "You can create at most {{count}} servers on this instance.",
|
||||||
"limitReached": "You have reached the server limit — remove one of your servers first."
|
"limitReached": "You have reached the server limit — remove one of your servers first.",
|
||||||
|
"template": "Server template",
|
||||||
|
"templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "You have no servers yet",
|
"title": "You have no servers yet",
|
||||||
|
|||||||
@@ -194,7 +194,9 @@
|
|||||||
"hint": "Название можно изменить позже в настройках сервера.",
|
"hint": "Название можно изменить позже в настройках сервера.",
|
||||||
"submit": "Создать",
|
"submit": "Создать",
|
||||||
"limit": "На инстансе можно создать не более {{count}} серверов.",
|
"limit": "На инстансе можно создать не более {{count}} серверов.",
|
||||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
|
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.",
|
||||||
|
"template": "Шаблон сервера",
|
||||||
|
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "У вас пока нет серверов",
|
"title": "У вас пока нет серверов",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { createGuild } from '@/api/guilds';
|
import { createGuild, fetchGuildTemplates, guildTemplatesQueryKey } from '@/api/guilds';
|
||||||
import { instanceQueryKey } from '@/api/instance';
|
import { instanceQueryKey } from '@/api/instance';
|
||||||
import { myGuildsQueryKey } from '@/api/users';
|
import { myGuildsQueryKey } from '@/api/users';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
@@ -31,9 +31,19 @@ export function CreateGuildModal() {
|
|||||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [templateId, setTemplateId] = useState('empty');
|
||||||
|
|
||||||
|
// Шаблоны структуры: список небольшой и статический, кэшируется на сессию.
|
||||||
|
const templates = useQuery({
|
||||||
|
queryKey: guildTemplatesQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchGuildTemplates(signal),
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 300_000,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (guildName: string) => createGuild(guildName),
|
mutationFn: (guildName: string) => createGuild(guildName, templateId),
|
||||||
onSuccess: (guild) => {
|
onSuccess: (guild) => {
|
||||||
close();
|
close();
|
||||||
setName('');
|
setName('');
|
||||||
@@ -83,6 +93,43 @@ export function CreateGuildModal() {
|
|||||||
maxLength={64}
|
maxLength={64}
|
||||||
hint={t('guilds.create.hint')}
|
hint={t('guilds.create.hint')}
|
||||||
/>
|
/>
|
||||||
|
{/* Шаблон задаёт структуру комнат и ролей (AGENT.md 7.3). */}
|
||||||
|
<fieldset className="flex flex-col gap-2" data-testid="guild-templates">
|
||||||
|
<legend className="text-sm font-medium">{t('guilds.create.template')}</legend>
|
||||||
|
{(templates.data ?? []).map((template) => (
|
||||||
|
<label
|
||||||
|
key={template.id}
|
||||||
|
data-testid={`guild-template-${template.id}`}
|
||||||
|
className={`flex cursor-pointer flex-col gap-0.5 rounded-[var(--radius-md)] border px-3 py-2 transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
templateId === template.id
|
||||||
|
? 'border-accent bg-accent/10'
|
||||||
|
: 'border-border/60 hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="guild_template"
|
||||||
|
value={template.id}
|
||||||
|
checked={templateId === template.id}
|
||||||
|
onChange={() => setTemplateId(template.id)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{template.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-fg-muted">
|
||||||
|
{t('guilds.create.templateCounts', {
|
||||||
|
text: template.text_channels,
|
||||||
|
voice: template.voice_channels,
|
||||||
|
roles: template.roles.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-fg-muted">{template.description}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{templates.isError ? (
|
||||||
|
<ErrorNotice error={templates.error} onRetry={() => void templates.refetch()} />
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
{instance.data === undefined ? null : (
|
{instance.data === undefined ? null : (
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="text-xs text-fg-muted">
|
||||||
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
|
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
|
||||||
|
|||||||
@@ -322,3 +322,64 @@ describe('оформление «для друзей»', () => {
|
|||||||
expect(within(row).getByTestId('nick-name')).toHaveStyle({ color: '#2ecc71' });
|
expect(within(row).getByTestId('nick-name')).toHaveStyle({ color: '#2ecc71' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('шаблоны серверов', () => {
|
||||||
|
it('создаёт сервер по выбранному шаблону', async () => {
|
||||||
|
const templates = [
|
||||||
|
{
|
||||||
|
id: 'empty',
|
||||||
|
name: 'Пустой',
|
||||||
|
description: 'Одна общая текстовая комната.',
|
||||||
|
roles: ['Администратор', 'Пользователь'],
|
||||||
|
text_channels: 1,
|
||||||
|
voice_channels: 0,
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gaming',
|
||||||
|
name: 'Игровой',
|
||||||
|
description: 'Текстовые комнаты и две голосовые.',
|
||||||
|
roles: ['Администратор', 'Пользователь', 'Игрок'],
|
||||||
|
text_channels: 2,
|
||||||
|
voice_channels: 2,
|
||||||
|
categories: ['Текстовые', 'Голосовые'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const fetchMock = installFetch([
|
||||||
|
{ match: '/api/v1/guild-templates', response: () => json({ templates }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds',
|
||||||
|
method: 'POST',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
guild: {
|
||||||
|
id: 'g-new',
|
||||||
|
name: 'Новый',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: false,
|
||||||
|
member_count: 1,
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: ['ADMINISTRATOR'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
]);
|
||||||
|
renderApp('/app');
|
||||||
|
await userEvent.click(await screen.findByLabelText('Создать сервер'));
|
||||||
|
|
||||||
|
const picker = await screen.findByTestId('guild-templates');
|
||||||
|
await userEvent.click(within(picker).getByTestId('guild-template-gaming'));
|
||||||
|
await userEvent.type(screen.getByLabelText('Название сервера'), 'Новый');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Создать' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(bodyOf(findRequest(fetchMock, { url: '/guilds', method: 'POST' }))).toMatchObject({
|
||||||
|
name: 'Новый',
|
||||||
|
template_id: 'gaming',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ describe('основной экран', () => {
|
|||||||
const createCall = recordedRequests(fetchMock).find(
|
const createCall = recordedRequests(fetchMock).find(
|
||||||
(request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST',
|
(request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST',
|
||||||
);
|
);
|
||||||
expect(createCall?.body).toEqual({ name: 'Новый' });
|
// Шаблон по умолчанию — «Пустой»: структура комнат и ролей (AGENT.md 7.3).
|
||||||
|
expect(createCall?.body).toEqual({ name: 'Новый', template_id: 'empty' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user