Add Rec Center doors management: implement endpoints for retrieving and updating door configurations; enhance database schema and UI integration

This commit is contained in:
Alexa
2026-06-02 17:08:42 +03:00
parent 218d92f856
commit 810ba0992c
8 changed files with 428 additions and 34 deletions
@@ -1,9 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameConfigurations;
[ApiController]
public class GameConfigurationsController : ControllerBase
public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase
{
// Actual path used by the 2020 build
[HttpGet("api/gameconfigs/v1/all")]
@@ -11,5 +12,6 @@ public class GameConfigurationsController : ControllerBase
// Legacy path we originally assumed
[HttpGet("api/gameconfigurations/v1/all")]
[HttpGet("api/gameconfigurations/v1")]
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
public async Task<ActionResult<IReadOnlyList<GameConfigurationSetting>>> GetAll()
=> Ok(await serverSettings.GetGameConfigurationsAsync());
}
@@ -1514,6 +1514,23 @@ public class AdminController(
return Ok(tags);
}
[HttpGet("settings/rec-center-doors")]
public async Task<ActionResult> GetRecCenterDoors()
=> Ok(await serverSettings.GetRecCenterDoorsAsync());
public sealed record RecCenterDoorsRequest(
List<RecCenterDoorConfig>? Doors);
[HttpPost("settings/rec-center-doors")]
public async Task<ActionResult> SetRecCenterDoors([FromBody] RecCenterDoorsRequest body)
{
var doors = await serverSettings.SetRecCenterDoorsAsync(body.Doors ?? []);
await LogAsync("rec_center_doors_updated", "system", 0,
string.Join(";", doors.Doors.Select(d => $"{d.Key}:{d.Query}")));
await db.SaveChangesAsync();
return Ok(doors);
}
// ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
+1
View File
@@ -610,6 +610,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.Property(s => s.WeeklyChallengesJson).HasDefaultValue(string.Empty);
e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty);
e.Property(s => s.PlayMenuTagsJson).HasDefaultValue(string.Empty);
e.Property(s => s.RecCenterDoorsJson).HasDefaultValue(string.Empty);
});
modelBuilder.Entity<RoomKeyEntity>(e =>
@@ -43,5 +43,10 @@ public class ServerSettingsEntity
/// the watch without leading # characters.</summary>
public string PlayMenuTagsJson { get; set; } = string.Empty;
/// <summary>Admin-managed Rec Center category-door config. The
/// 2020.12 client reads this through api/gameconfigs/v1/all as
/// Door.{Category}.Title and Door.{Category}.Query entries.</summary>
public string RecCenterDoorsJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+3 -3
View File
@@ -45,15 +45,15 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
// config lookups. Missing values leave the door browser
// and door-specific return spawns without a category.
["Door.Shooters.Title"] = "Shooters",
["Door.Shooters.Query"] = "#paintball,#lasertag,#recroyale",
["Door.Shooters.Query"] = "#paintball|#lasertag|#recroyale",
["Door.Creative.Title"] = "Creative",
["Door.Creative.Query"] = "#creative,#makerpen,#template",
["Door.Creative.Query"] = "#creative|#makerpen|#template",
["Door.Quests.Title"] = "Quests",
["Door.Quests.Query"] = "#quest",
["Door.Sports.Title"] = "Sports",
["Door.Sports.Query"] = "#sport",
["Door.Featured.Title"] = "Featured",
["Door.Featured.Query"] = "#featured",
["Door.Featured.Query"] = "#featured|#recroomoriginal",
},
};
}
@@ -60,6 +60,18 @@ public class ServerSettingsService(DorkNetDbContext db)
return ToPlayMenuTags(row);
}
public async Task<RecCenterDoorSettings> GetRecCenterDoorsAsync()
{
var row = await GetAsync();
return ToRecCenterDoors(row);
}
public async Task<IReadOnlyList<GameConfigurationSetting>> GetGameConfigurationsAsync()
{
var doors = await GetRecCenterDoorsAsync();
return ToGameConfigurations(doors);
}
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
IReadOnlyList<string> pinned,
IReadOnlyList<string> popular)
@@ -72,6 +84,17 @@ public class ServerSettingsService(DorkNetDbContext db)
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task<RecCenterDoorSettings> SetRecCenterDoorsAsync(
IReadOnlyList<RecCenterDoorConfig> doors)
{
var normalized = NormalizeRecCenterDoors(doors);
var existing = await GetTrackedRowAsync();
existing.RecCenterDoorsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
bool completedRequired,
IReadOnlyList<WeeklyChallengeTemplate> challenges,
@@ -163,6 +186,29 @@ public class ServerSettingsService(DorkNetDbContext db)
return DefaultPlayMenuTags() with { UpdatedAt = row.UpdatedAt };
}
private static RecCenterDoorSettings ToRecCenterDoors(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.RecCenterDoorsJson))
{
try
{
var parsed = JsonSerializer.Deserialize<RecCenterDoorSettings>(
row.RecCenterDoorsJson,
JsonOptions);
if (parsed is not null)
return NormalizeRecCenterDoors(parsed.Doors)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break Rec Center startup; admins
// can overwrite them from the SPA.
}
}
return DefaultRecCenterDoors() with { UpdatedAt = row.UpdatedAt };
}
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
IReadOnlyList<WeeklyChallengeTemplate> challenges)
{
@@ -265,6 +311,42 @@ public class ServerSettingsService(DorkNetDbContext db)
],
UpdatedAt: DateTime.UtcNow);
public static RecCenterDoorSettings DefaultRecCenterDoors() =>
new(
Doors:
[
new("Shooters", "Shooters", "#paintball|#lasertag|#recroyale"),
new("Creative", "Creative", "#creative|#makerpen|#template"),
new("Quests", "Quests", "#quest"),
new("Sports", "Sports", "#sport"),
new("Featured", "Featured", "#featured|#recroomoriginal"),
],
UpdatedAt: DateTime.UtcNow);
public static IReadOnlyList<GameConfigurationSetting> ToGameConfigurations(
RecCenterDoorSettings doors)
{
var rows = new List<GameConfigurationSetting>();
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
{
rows.Add(new($"Door.{door.Key}.Title", door.Title));
rows.Add(new($"Door.{door.Key}.Query", door.Query));
}
rows.AddRange([
new("Rewards.UseRewardSelection", "false"),
new("RoomDetails.PhotoRollEnabled", "false"),
new("SynchronizedField.RemoveDefaultEntries", "false"),
new("Rendering.DisableSrpBatcher", "false"),
new("splitTestSoftOverrides", "{}"),
new("splitTestHardOverrides", "{}"),
new("splitTestSegmentProbabilities", "{}"),
new("loadingNetworkTimeout", "30"),
new("runningNetworkTimeout", "30"),
]);
return rows;
}
private static PlayMenuTagSettings NormalizePlayMenuTags(
IReadOnlyList<string> pinned,
IReadOnlyList<string> popular)
@@ -289,6 +371,50 @@ public class ServerSettingsService(DorkNetDbContext db)
popularTags.Count > 0 ? popularTags : defaults.PopularTags,
DateTime.UtcNow);
}
private static RecCenterDoorSettings NormalizeRecCenterDoors(
IReadOnlyList<RecCenterDoorConfig> doors)
{
static string NormalizeKey(string value)
{
var clean = new string((value ?? string.Empty)
.Where(char.IsLetterOrDigit)
.ToArray());
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean;
}
static string NormalizeQuery(string value)
{
var tags = (value ?? string.Empty)
.Split(['|', ',', '\n', '\r', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.Trim().TrimStart('#').ToLowerInvariant())
.Where(t => t.Length > 0)
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
.Select(t => $"#{t}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(12)
.ToList();
return string.Join("|", tags);
}
var normalized = doors
.Select(d =>
{
var key = NormalizeKey(d.Key);
var title = string.IsNullOrWhiteSpace(d.Title) ? key : d.Title.Trim();
var query = NormalizeQuery(d.Query);
return new RecCenterDoorConfig(key, title, query);
})
.Where(d => d.Key.Length > 0 && d.Query.Length > 0)
.GroupBy(d => d.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.Take(8)
.ToList();
return new RecCenterDoorSettings(
normalized.Count > 0 ? normalized : DefaultRecCenterDoors().Doors,
DateTime.UtcNow);
}
}
public sealed record WeeklyChallengeSettings(
@@ -320,3 +446,18 @@ public sealed record PlayMenuTagSettings(
List<string> PinnedTags,
List<string> PopularTags,
DateTime UpdatedAt);
public sealed record RecCenterDoorSettings(
List<RecCenterDoorConfig> Doors,
DateTime UpdatedAt);
public sealed record RecCenterDoorConfig(
string Key,
string Title,
string Query);
public sealed record GameConfigurationSetting(
string Key,
string Value,
DateTime? StartTime = null,
DateTime? EndTime = null);
@@ -244,6 +244,8 @@ public static class DatabaseBootstrap
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""PlayMenuTagsJson"" text NOT NULL DEFAULT '';");
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RecCenterDoorsJson"" text NOT NULL DEFAULT '';");
return;
}
@@ -258,6 +260,18 @@ public static class DatabaseBootstrap
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
try
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""RecCenterDoorsJson"" TEXT NOT NULL DEFAULT '';");
}
catch
{
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
// ignore the duplicate-column error; any other schema problem
// will surface when EF reads ServerSettings.
}
}
/// <summary>SQLite migration-history reconciliation. Handles two
+243 -29
View File
@@ -8,7 +8,7 @@ import { Empty } from '../components/Empty';
import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm';
import { RefreshCw, Trash, Upload } from '../components/Icons';
import { Plus, RefreshCw, Trash, Upload } from '../components/Icons';
interface PlayMenuTagSettings {
pinnedTags: string[];
@@ -16,6 +16,17 @@ interface PlayMenuTagSettings {
updatedAt: string;
}
interface RecCenterDoorConfig {
key: string;
title: string;
query: string;
}
interface RecCenterDoorSettings {
doors: RecCenterDoorConfig[];
updatedAt: string;
}
export function Rooms() {
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
const {
@@ -24,6 +35,12 @@ export function Rooms() {
error: tagsError,
refresh: refreshPlayMenuTags,
} = useApi<PlayMenuTagSettings>('/settings/play-menu-tags');
const {
data: recCenterDoors,
loading: doorsLoading,
error: doorsError,
refresh: refreshRecCenterDoors,
} = useApi<RecCenterDoorSettings>('/settings/rec-center-doors');
const [filter, setFilter] = useState('');
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
@@ -83,6 +100,13 @@ export function Rooms() {
onSaved={refreshPlayMenuTags}
/>
<RecCenterDoorsPanel
settings={recCenterDoors}
loading={doorsLoading}
error={doorsError}
onSaved={refreshRecCenterDoors}
/>
<div className="card overflow-hidden">
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
@@ -186,23 +210,17 @@ function PlayMenuTagsPanel({
error: string | null;
onSaved: () => void;
}) {
const [pinnedText, setPinnedText] = useState('');
const [popularText, setPopularText] = useState('');
const [pinnedTags, setPinnedTags] = useState<string[]>([]);
const [popularTags, setPopularTags] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const toast = useToast();
useEffect(() => {
if (!tags) return;
setPinnedText(tags.pinnedTags.join('\n'));
setPopularText(tags.popularTags.join('\n'));
setPinnedTags(tags.pinnedTags);
setPopularTags(tags.popularTags);
}, [tags]);
const parseTags = (text: string) =>
text
.split(/[\n,]+/)
.map(t => t.trim().replace(/^#+/, '').toLowerCase())
.filter(Boolean);
const save = async (reset = false) => {
setBusy(true);
try {
@@ -210,7 +228,7 @@ function PlayMenuTagsPanel({
method: 'POST',
body: reset
? { PinnedTags: [], PopularTags: [] }
: { PinnedTags: parseTags(pinnedText), PopularTags: parseTags(popularText) },
: { PinnedTags: pinnedTags, PopularTags: popularTags },
});
toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success');
onSaved();
@@ -227,7 +245,7 @@ function PlayMenuTagsPanel({
<div>
<h2 className="text-sm font-semibold text-ink-50">Play menu tags</h2>
<p className="mt-1 text-xs text-ink-400">
Filter chips shown in the watch Play menu. Use one tag per line or commas; leave off the #.
Filter chips shown in the watch Play menu.
</p>
{tags && (
<p className="mt-1 text-[11px] text-ink-500">
@@ -249,28 +267,224 @@ function PlayMenuTagsPanel({
{loading && !tags && <div className="mt-3 text-xs text-ink-400">Loading tags...</div>}
{tags && (
<div className="mt-4 grid gap-3 md:grid-cols-2">
<label className="block">
<span className="label">Pinned tags</span>
<textarea
value={pinnedText}
onChange={e => setPinnedText(e.target.value)}
className="input mt-1 min-h-36 font-mono text-xs"
/>
</label>
<label className="block">
<span className="label">Popular tags</span>
<textarea
value={popularText}
onChange={e => setPopularText(e.target.value)}
className="input mt-1 min-h-36 font-mono text-xs"
/>
</label>
<TagListEditor
label="Pinned tags"
tags={pinnedTags}
onChange={setPinnedTags}
max={16}
/>
<TagListEditor
label="Popular tags"
tags={popularTags}
onChange={setPopularTags}
max={32}
/>
</div>
)}
</div>
);
}
function RecCenterDoorsPanel({
settings,
loading,
error,
onSaved,
}: {
settings: RecCenterDoorSettings | null;
loading: boolean;
error: string | null;
onSaved: () => void;
}) {
const [doors, setDoors] = useState<RecCenterDoorConfig[]>([]);
const [busy, setBusy] = useState(false);
const toast = useToast();
useEffect(() => {
if (!settings) return;
setDoors(settings.doors);
}, [settings]);
const updateDoor = (index: number, patch: Partial<RecCenterDoorConfig>) => {
setDoors(current => current.map((door, i) => i === index ? { ...door, ...patch } : door));
};
const removeDoor = (index: number) => {
setDoors(current => current.filter((_, i) => i !== index));
};
const addDoor = () => {
setDoors(current => [...current, { key: 'New', title: 'New', query: '#tag' }]);
};
const save = async (reset = false) => {
setBusy(true);
try {
await api<RecCenterDoorSettings>('/settings/rec-center-doors', {
method: 'POST',
body: reset ? { Doors: [] } : { Doors: doors },
});
toast.push(reset ? 'Rec Center doors reset.' : 'Rec Center doors saved.', 'success');
onSaved();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div className="card !p-4 mb-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink-50">Rec Center doors</h2>
<p className="mt-1 text-xs text-ink-400">
Door titles and tag queries served through the 2020 gameconfig endpoint.
</p>
{settings && (
<p className="mt-1 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
)}
</div>
<div className="flex gap-2">
<button onClick={addDoor} disabled={busy || loading} className="btn-secondary text-xs">
<Plus /> Add
</button>
<button onClick={() => save(true)} disabled={busy || loading} className="btn-secondary text-xs">
Defaults
</button>
<button onClick={() => save(false)} disabled={busy || loading} className="btn-primary text-xs">
{busy ? 'Saving...' : 'Save doors'}
</button>
</div>
</div>
{error && <div className="mt-3 text-sm text-danger">{error}</div>}
{loading && !settings && <div className="mt-3 text-xs text-ink-400">Loading Rec Center doors...</div>}
{settings && (
<div className="mt-4 table-scroll">
<table className="w-full min-w-[760px] text-sm">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-y border-ink-800">
<tr>
<th className="text-left font-medium px-3 py-2">Key</th>
<th className="text-left font-medium px-3 py-2">Title</th>
<th className="text-left font-medium px-3 py-2">Query tags</th>
<th className="w-12" />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{doors.map((door, index) => (
<tr key={`${door.key}-${index}`}>
<td className="px-3 py-2 align-top">
<input
value={door.key}
onChange={e => updateDoor(index, { key: e.target.value })}
className="input font-mono text-xs"
placeholder="Shooters"
/>
<div className="mt-1 text-[11px] text-ink-500">Door.{door.key || 'Key'}.*</div>
</td>
<td className="px-3 py-2 align-top">
<input
value={door.title}
onChange={e => updateDoor(index, { title: e.target.value })}
className="input"
placeholder="Shooters"
/>
</td>
<td className="px-3 py-2 align-top">
<input
value={door.query}
onChange={e => updateDoor(index, { query: e.target.value })}
className="input font-mono text-xs"
placeholder="#paintball|#lasertag"
/>
</td>
<td className="px-3 py-2 align-top text-right">
<button
onClick={() => removeDoor(index)}
className="btn-ghost text-xs text-danger"
title="Remove door"
>
<Trash />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function TagListEditor({
label,
tags,
onChange,
max,
}: {
label: string;
tags: string[];
onChange: (tags: string[]) => void;
max: number;
}) {
const [draft, setDraft] = useState('');
const normalize = (value: string) =>
value.trim().replace(/^#+/, '').toLowerCase();
const add = () => {
const next = normalize(draft);
if (!next || tags.includes(next) || tags.length >= max) return;
onChange([...tags, next]);
setDraft('');
};
const remove = (tag: string) => {
onChange(tags.filter(t => t !== tag));
};
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<div className="flex items-center justify-between gap-3">
<span className="label">{label}</span>
<span className="text-[11px] text-ink-500">{tags.length}/{max}</span>
</div>
<div className="mt-2 flex gap-2">
<input
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
add();
}
}}
className="input font-mono text-xs"
placeholder="paintball"
/>
<button onClick={add} disabled={!draft.trim() || tags.length >= max} className="btn-secondary text-xs">
<Plus /> Add
</button>
</div>
<div className="mt-3 flex min-h-20 flex-wrap content-start gap-2">
{tags.map(tag => (
<span key={tag} className="inline-flex items-center gap-1 rounded-md border border-ink-700 bg-ink-900 px-2 py-1 text-xs text-ink-100">
<span className="font-mono">#{tag}</span>
<button onClick={() => remove(tag)} className="text-ink-500 hover:text-danger" title={`Remove ${tag}`}>
×
</button>
</span>
))}
{tags.length === 0 && <span className="text-xs text-ink-500">No tags set. Defaults will be used on save.</span>}
</div>
</div>
);
}
// ── Per-room hard-delete modal with two typed-name confirmations ─────────
// The previous bulk "purge all custom" button was removed — too easy to
// click by accident. Each room now gets its own purge flow that requires