diff --git a/backend/internal/adapter/httpapi/server.go b/backend/internal/adapter/httpapi/server.go index 91a97b9..0d4664b 100644 --- a/backend/internal/adapter/httpapi/server.go +++ b/backend/internal/adapter/httpapi/server.go @@ -71,6 +71,7 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu api.Put("/api/events/{eventID}", s.updateEvent) api.Get("/api/events/{eventID}/tournament", s.getEventTournament) api.Get("/api/events/{eventID}/rsvps", s.rsvps) + api.Post("/api/events/{eventID}/participants", s.createParticipant) api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP) api.Post("/api/events/{eventID}/balance", s.balance) api.Put("/api/events/{eventID}/teams", s.selectBalance) @@ -277,6 +278,19 @@ func (s *Server) rsvps(w http.ResponseWriter, r *http.Request) { respond(w, out, err, 200) } +func (s *Server) createParticipant(w http.ResponseWriter, r *http.Request) { + var in struct { + DisplayName string `json:"displayName"` + Ratings domain.Ratings `json:"ratings"` + Status domain.RSVPStatus `json:"status"` + } + if !decode(w, r, &in) { + return + } + player, rsvp, err := s.service.CreateParticipant(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.DisplayName, in.Ratings, in.Status) + respond(w, map[string]any{"player": player, "rsvp": rsvp}, err, http.StatusCreated) +} + func (s *Server) setRSVP(w http.ResponseWriter, r *http.Request) { var in struct { Status domain.RSVPStatus `json:"status"` diff --git a/backend/internal/adapter/postgres/store.go b/backend/internal/adapter/postgres/store.go index 2269652..eb932e2 100644 --- a/backend/internal/adapter/postgres/store.go +++ b/backend/internal/adapter/postgres/store.go @@ -122,7 +122,7 @@ func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Playe } func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) { - rows, err := s.pool.Query(ctx, `SELECT id,account_id,display_name,tank_rating,damage_rating,support_rating, + rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating, preferred_roles,preferred_player_ids,created_at,updated_at FROM players ORDER BY id`) if err != nil { return nil, err @@ -147,6 +147,40 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) { return out, rows.Err() } +func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain.RSVP) (domain.Player, domain.RSVP, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return domain.Player{}, domain.RSVP{}, err + } + defer tx.Rollback(ctx) + var preferredRoles, preferredPlayers []byte + err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at) + VALUES($1,NULL,$2,$3,$4,$5,$6,$7) + RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,created_at,updated_at`, + p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt). + Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt) + if err == nil { + err = json.Unmarshal(preferredRoles, &p.PreferredRoles) + } + if err == nil { + err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs) + } + if err == nil { + err = tx.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at) + VALUES($1,$2,$3,$4,$5,$6) + RETURNING event_id,player_id,status,actor_account_id,source,updated_at`, + r.EventID, r.PlayerID, r.Status, r.ActorAccountID, r.Source, r.UpdatedAt). + Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt) + } + if err == nil { + err = tx.Commit(ctx) + } + if err != nil { + return domain.Player{}, domain.RSVP{}, mapError(err) + } + return p, r, nil +} + func (s *Store) CreateEvent(ctx context.Context, e domain.Event) (domain.Event, error) { err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index 36a4253..7240282 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strings" "time" "mixmaker/backend/internal/domain" @@ -18,6 +19,7 @@ type Store interface { DeleteSession(context.Context, string) error UpdatePlayer(context.Context, domain.Player) (domain.Player, error) ListPlayers(context.Context) ([]domain.Player, error) + CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error) CreateEvent(context.Context, domain.Event) (domain.Event, error) GetEvent(context.Context, string) (domain.Event, error) UpdateEvent(context.Context, domain.Event) (domain.Event, error) @@ -174,6 +176,47 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer return out, err } +func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, eventID, displayName string, ratings domain.Ratings, status domain.RSVPStatus) (domain.Player, domain.RSVP, error) { + if !actor.IsAdmin() { + return domain.Player{}, domain.RSVP{}, domain.ErrForbidden + } + displayName = strings.TrimSpace(displayName) + if displayName == "" { + return domain.Player{}, domain.RSVP{}, fmt.Errorf("%w: display name is required", domain.ErrInvalid) + } + if err := ratings.Validate(); err != nil { + return domain.Player{}, domain.RSVP{}, err + } + if _, err := s.Store.GetEvent(ctx, eventID); err != nil { + return domain.Player{}, domain.RSVP{}, err + } + now := s.Now() + player := domain.Player{ + ID: NewID(), + DisplayName: displayName, + Ratings: ratings, + CreatedAt: now, + UpdatedAt: now, + } + rsvp := domain.RSVP{ + EventID: eventID, + PlayerID: player.ID, + ActorAccountID: actor.ID, + Status: status, + Source: domain.SourceAdmin, + UpdatedAt: now, + } + if err := rsvp.Validate(); err != nil { + return domain.Player{}, domain.RSVP{}, err + } + player, rsvp, err := s.Store.CreateParticipant(ctx, player, rsvp) + if err == nil { + _ = s.Store.AppendAudit(ctx, actor.ID, "participant.created", eventID, map[string]any{"player": player, "rsvp": rsvp}) + s.Bus.Publish("event:"+eventID, rsvp) + } + return player, rsvp, err +} + func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) { if !actor.IsAdmin() { return nil, domain.ErrForbidden diff --git a/backend/migrations/005_guest_players.sql b/backend/migrations/005_guest_players.sql new file mode 100644 index 0000000..f381b0b --- /dev/null +++ b/backend/migrations/005_guest_players.sql @@ -0,0 +1,9 @@ +-- +mixmaker Up +ALTER TABLE players + ALTER COLUMN account_id DROP NOT NULL; + +-- +mixmaker Down +DELETE FROM rsvps WHERE player_id IN (SELECT id FROM players WHERE account_id IS NULL); +DELETE FROM players WHERE account_id IS NULL; +ALTER TABLE players + ALTER COLUMN account_id SET NOT NULL; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e81ef92..6b8e0a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { } from '@tanstack/react-router' import { api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent, - type PlayerRole, type RsvpStatus, type Series, type Session, type Team, + type PlayerRole, type Registration, type RsvpStatus, type Series, type Session, type Team, } from './api/client' import { demoBalanceCandidates, demoBracket, demoEvents, demoProfile, @@ -144,10 +144,12 @@ function EventPage() { const event = eventQuery.data const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts + const statusOrder: Record = { going: 0, maybe: 1, not_going: 2 } + const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName)) return
Registration open} />
Starts{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
Registration closes{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}

Are you playing?

Your latest response is sent to the organizer.

{(Object.keys(labels) as RsvpStatus[]).map((status) => )}
{rsvpMutation.isSuccess &&
Response saved · Server confirmed just now
}{rsvpMutation.isError &&

{rsvpMutation.error.message}

}
-
+

Registered players

Everyone can see who responded and their current status.

{attendees.length === 0 ?

No responses yet.

:
{attendees.map((registration) =>
{registration.player.displayName.slice(0, 2).toUpperCase()}{registration.player.displayName}{labels[registration.status]}
)}
}
} function ProfilePage() { @@ -234,7 +236,38 @@ function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event: function Participants({ eventId }: { eventId: string }) { const client = useQueryClient() const language = useLanguage() + const options = useMemo(() => rankOptions(language), [language]) + const [displayName, setDisplayName] = useState('') + const [status, setStatus] = useState('going') + const [ratings, setRatings] = useState>({ tank: 13, damage: 13, support: 13 }) const rows = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined }) + const create = useMutation({ + mutationFn: () => { + if (!demoMode) return api.createParticipant(eventId, displayName.trim(), ratings, status) + const now = new Date().toISOString() + const id = `guest-${Date.now()}` + return Promise.resolve({ + id: `${eventId}:${id}`, + player: { + id, + displayName: displayName.trim(), + ratings: { ...ratings, updatedAt: now }, + preferredRoles: [], + preferredPlayerIds: [], + }, + status, + updatedAt: now, + changedBy: { id: demoSession.account.id, displayName: demoSession.account.displayName, source: 'admin' }, + }) + }, + onSuccess: (registration) => { + client.setQueryData(['registrations', eventId], (current) => [...(current ?? []), registration]) + setDisplayName('') + setRatings({ tank: 13, damage: 13, support: 13 }) + setStatus('going') + if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] }) + }, + }) const override = useMutation({ mutationFn: ({ playerId, status }: { playerId: string; status: RsvpStatus }) => api.setRsvp(eventId, playerId, status), onSuccess: () => { @@ -245,7 +278,7 @@ function Participants({ eventId }: { eventId: string }) { if (rows.isLoading) return if (rows.isError || !rows.data) return void rows.refetch()} /> const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName])) - return

Participant responses

Admin overrides are attributed and audited.

{rows.data.filter((r) => r.status === 'going').length} going{rows.data.filter((r) => r.status === 'maybe').length} maybe
{rows.data.map((r) =>
{r.player.displayName.slice(0, 2).toUpperCase()}
{r.player.displayName}{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}}{r.changedBy?.source === 'admin' && <> · set by {r.changedBy.displayName}}
T · {rankLabel(r.player.ratings.tank, language)}D · {rankLabel(r.player.ratings.damage, language)}S · {rankLabel(r.player.ratings.support, language)}
)}
{override.isError &&

{override.error.message}

}
+ return
{ event.preventDefault(); create.mutate() }}>

Add participant

Create a guest player when they cannot sign in with Discord.

{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => )}
{create.isError &&

{create.error.message}

}

Participant responses

Admin overrides are attributed and audited.

{rows.data.filter((r) => r.status === 'going').length} going{rows.data.filter((r) => r.status === 'maybe').length} maybe
{rows.data.map((r) =>
{r.player.displayName.slice(0, 2).toUpperCase()}
{r.player.displayName}{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}}{r.changedBy?.source === 'admin' && <> · set by {r.changedBy.displayName}}
T · {rankLabel(r.player.ratings.tank, language)}D · {rankLabel(r.player.ratings.damage, language)}S · {rankLabel(r.player.ratings.support, language)}
)}
{override.isError &&

{override.error.message}

}
} function BalanceView({ eventId }: { eventId: string }) { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0ba3053..6af5523 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -442,6 +442,23 @@ export const api = { const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player])) return rsvps.map((rsvp) => adaptRegistration(rsvp, byId, adaptSession(session))) }, + createParticipant: async ( + eventId: string, + displayName: string, + ratings: Pick, + status: RsvpStatus, + ) => { + const result = await request<{ player: RawPlayer; rsvp: RawRSVP }>(`/events/${eventId}/participants`, { + method: 'POST', + body: JSON.stringify({ + displayName, + ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support }, + status: statusToRaw(status), + }), + }) + const player = adaptPlayer(result.player) + return adaptRegistration(result.rsvp, new Map([[player.id, player]])) + }, setRsvp: async (eventId: string, playerId: string, status: RsvpStatus) => { const [result, players, session] = await Promise.all([ request(`/events/${eventId}/rsvps/${playerId}`, { method: 'PUT', body: JSON.stringify({ status: statusToRaw(status) }) }), diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 01e7bd6..25d20ba 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -57,6 +57,9 @@ const translations: Record = { 'RSVP for': 'Статус участия для', 'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что', 'Attendance': 'Участие', + 'Registered players': 'Зарегистрированные игроки', + 'Everyone can see who responded and their current status.': 'Все могут видеть, кто ответил и какой статус выбрал.', + 'No responses yet.': 'Ответов пока нет.', 'No': 'Нет', 'Manage participants': 'Управлять участниками', 'Loading profile…': 'Загрузка профиля…', @@ -113,6 +116,14 @@ const translations: Record = { 'Loading participants…': 'Загрузка участников…', 'Participant responses': 'Ответы участников', 'Admin overrides are attributed and audited.': 'Изменения администратора подписываются и аудируются.', + 'Add participant': 'Добавить участника', + 'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.', + 'Nickname': 'Ник', + 'Tank rank': 'Ранг танка', + 'Damage rank': 'Ранг урона', + 'Support rank': 'Ранг поддержки', + 'Status': 'Статус', + 'Adding…': 'Добавление…', 'Not going': 'Не идёт', 'Preferred with': 'Предпочитает играть с', 'No preferred roles': 'Нет предпочитаемых ролей', diff --git a/frontend/src/index.css b/frontend/src/index.css index e19f71a..2711689 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -124,6 +124,11 @@ main { min-height: calc(100vh - 72px); } .attendance-stats strong { margin-left: auto; color: #fff; } .attendance-stats i { width: 6px; height: 6px; border-radius: 50%; background: #59616b; } .attendance-stats i.green { background: var(--green); }.attendance-stats i.yellow { background: var(--yellow); } +.public-roster { margin-top: 18px; padding: 24px 28px; } +.public-roster .section-title { margin-top: 0; } +.public-roster-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; } +.public-roster-player { min-height: 52px; display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); } +.public-roster-player strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; } .large-avatar { width: 70px; height: 70px; font-size: 21px; } @@ -158,6 +163,14 @@ main { min-height: calc(100vh - 72px); } .settings-card, .admin-summary, .table-card { padding: 24px; } .settings-card .section-title, .table-card .section-title { margin-top: 0; } .field-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 16px; } +.guest-participant-form { margin-bottom: 24px; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); } +.guest-participant-form > div:first-child { margin-bottom: 14px; } +.guest-participant-form h2 { font-size: 16px; } +.guest-participant-form p { margin-top: 4px; color: var(--muted); font-size: 10px; } +.guest-participant-fields { display: grid; grid-template-columns: minmax(150px,1.4fr) repeat(4,minmax(120px,1fr)) auto; align-items: end; gap: 10px; } +.guest-participant-fields label { display: flex; flex-direction: column; gap: 6px; color: #b6bcc4; font-size: 10px; font-weight: 700; } +.guest-participant-fields input, .guest-participant-fields select { width: 100%; min-height: 40px; } +.guest-participant-fields .button { min-height: 40px; white-space: nowrap; } .settings-card label { display: flex; flex-direction: column; gap: 8px; color: #b6bcc4; font-size: 11px; font-weight: 700; margin-bottom: 16px; } input, select, textarea { color: #e9ebed; background: #0e1115; border: 1px solid #303640; border-radius: 6px; padding: 11px 12px; } textarea { min-height: 85px; resize: vertical; } @@ -247,10 +260,10 @@ textarea { min-height: 85px; resize: vertical; } .bottom-nav a { min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: #747c87; font-size: 9px; font-weight: 700; }.bottom-nav a svg { font-size: 18px; }.bottom-nav a.active { color: var(--orange); } .featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; } .event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; } - .detail-layout { gap: 10px; }.event-hero,.roster-preview { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; } + .detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; } .profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; } .teammate-grid { grid-template-columns: repeat(2,1fr); } - .settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; } + .settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; } .candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; } .scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; } .turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f9eb19f..0221b71 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -195,6 +195,43 @@ paths: type: array items: $ref: "#/components/schemas/EventRegistration" + /api/events/{eventId}/participants: + post: + tags: [Events] + operationId: createGuestParticipant + parameters: + - $ref: "#/components/parameters/EventId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [displayName, ratings, status] + properties: + displayName: + type: string + minLength: 1 + ratings: + $ref: "#/components/schemas/Ratings" + status: + type: string + enum: [Going, Maybe, NotGoing] + responses: + "201": + description: Guest participant created and registered + content: + application/json: + schema: + type: object + required: [player, rsvp] + properties: + player: + $ref: "#/components/schemas/Player" + rsvp: + $ref: "#/components/schemas/EventRegistration" + "403": + $ref: "#/components/responses/Forbidden" /api/events/{eventId}/rsvps/{playerId}: put: tags: [Events]