diff --git a/apps/server/drizzle/0036_mod_search_terms.sql b/apps/server/drizzle/0036_mod_search_terms.sql new file mode 100644 index 0000000..936aa40 --- /dev/null +++ b/apps/server/drizzle/0036_mod_search_terms.sql @@ -0,0 +1,6 @@ +-- Admin-owned alternative search terms per mod (e.g. "wimf" for "What's in +-- my Fool") -- same "never synced from the index" shape as hidden/featured/ +-- ranked_version, but editable alongside categories via the general +-- field-edit endpoint rather than its own dedicated route. See schema.ts's +-- own doc comment on mod_registry.search_terms. +ALTER TABLE "mod_registry" ADD COLUMN "search_terms" text[] DEFAULT '{}' NOT NULL; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 5026c12..2efcacd 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1791100000000, "tag": "0035_blog_posts", "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1791200000000, + "tag": "0036_mod_search_terms", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/features/webadmin/mods.route.ts b/apps/server/src/features/webadmin/mods.route.ts index 455b4f4..184c3da 100644 --- a/apps/server/src/features/webadmin/mods.route.ts +++ b/apps/server/src/features/webadmin/mods.route.ts @@ -271,6 +271,15 @@ router.patch('/mods/:modId', async (req, res, next) => { } input.categories = body.categories as string[] } + if (body.searchTerms !== undefined) { + if ( + !Array.isArray(body.searchTerms) || + !body.searchTerms.every((t) => typeof t === 'string') + ) { + throw new AppError('searchTerms must be a string array', 400) + } + input.searchTerms = body.searchTerms as string[] + } if (body.requiresSteamodded !== undefined) { if (typeof body.requiresSteamodded !== 'boolean') throw new AppError('requiresSteamodded must be a boolean', 400) @@ -406,6 +415,9 @@ router.post('/mods', async (req, res, next) => { categories: Array.isArray(body.categories) ? (body.categories as string[]) : undefined, + searchTerms: Array.isArray(body.searchTerms) + ? (body.searchTerms as string[]) + : undefined, requiresSteamodded: typeof body.requiresSteamodded === 'boolean' ? body.requiresSteamodded @@ -481,6 +493,9 @@ router.put('/mods/:modId/custom', async (req, res, next) => { categories: Array.isArray(body.categories) ? (body.categories as string[]) : undefined, + searchTerms: Array.isArray(body.searchTerms) + ? (body.searchTerms as string[]) + : undefined, requiresSteamodded: bool('requiresSteamodded'), requiresTalisman: bool('requiresTalisman'), repoUrl: strOrNull('repoUrl'), diff --git a/apps/server/src/infrastructure/db/schema.ts b/apps/server/src/infrastructure/db/schema.ts index 1f52ecc..0fc5302 100644 --- a/apps/server/src/infrastructure/db/schema.ts +++ b/apps/server/src/infrastructure/db/schema.ts @@ -577,6 +577,18 @@ export const modRegistry = pgTable('mod_registry', { title: varchar('title', { length: 128 }).notNull(), author: varchar('author', { length: 128 }).notNull(), categories: text('categories').array().notNull().default(sql`'{}'::text[]`), + // Admin-owned aliases a mod is commonly known/searched by but that don't + // appear in its title -- e.g. "wimf" for "What's in my Fool". Unlike + // categories, this has no upstream-index counterpart at all (the base + // index carries no such concept), so it's never touched by + // upsertModFromIndex/SYNCABLE_MOD_FIELDS and never participates in + // overriddenFields -- same "permanently admin-owned" shape as featured/ + // hidden/rankedVersion above, just editable through the general PATCH + // .../mods/:modId field-edit endpoint alongside categories rather than + // its own dedicated PUT (see updateModFields()'s own comment). Matched + // case-insensitively as a substring, same as title, by whatever reads + // this for search (currently /admin/ranked-mods' filter box). + searchTerms: text('search_terms').array().notNull().default(sql`'{}'::text[]`), requiresSteamodded: boolean('requires_steamodded').notNull().default(true), requiresTalisman: boolean('requires_talisman').notNull().default(false), repoUrl: text('repo_url'), diff --git a/apps/server/src/infrastructure/gateways/mods.gateway.ts b/apps/server/src/infrastructure/gateways/mods.gateway.ts index 762cb1b..7b154ce 100644 --- a/apps/server/src/infrastructure/gateways/mods.gateway.ts +++ b/apps/server/src/infrastructure/gateways/mods.gateway.ts @@ -29,6 +29,11 @@ export async function listPublicMods(opts?: { includeHidden?: boolean }) { thumbnailUrl: modRegistry.thumbnailUrl, isCustom: modRegistry.isCustom, overriddenFields: modRegistry.overriddenFields, + // Included here (not just on the single-mod detail fetch) so + // /admin/ranked-mods' search box can filter the already-loaded list + // client-side without a second round trip per keystroke - see + // page.tsx's search filtering. + searchTerms: modRegistry.searchTerms, }) .from(modRegistry) .where(opts?.includeHidden ? undefined : eq(modRegistry.hidden, false)) @@ -510,6 +515,7 @@ export interface CustomModInput { title: string author: string categories?: string[] + searchTerms?: string[] requiresSteamodded?: boolean requiresTalisman?: boolean repoUrl?: string | null @@ -539,6 +545,7 @@ export async function createCustomMod( title: input.title, author: input.author, categories: input.categories ?? [], + searchTerms: input.searchTerms ?? [], requiresSteamodded: input.requiresSteamodded ?? true, requiresTalisman: input.requiresTalisman ?? false, repoUrl: input.repoUrl ?? null, @@ -572,6 +579,7 @@ export interface UpdateCustomModInput { title?: string author?: string categories?: string[] + searchTerms?: string[] requiresSteamodded?: boolean requiresTalisman?: boolean repoUrl?: string | null @@ -651,6 +659,12 @@ export async function updateModFields( touch('latestVersion', input.latestVersion) touch('latestDownloadUrl', input.latestDownloadUrl) + // Deliberately bypasses touch()/overriddenFields -- searchTerms has no + // upstream value to protect from a future sync (see schema.ts's own + // doc comment on mod_registry.searchTerms), so unlike every field + // above it's just written directly, on custom and synced mods alike. + if (input.searchTerms !== undefined) set.searchTerms = input.searchTerms + if (!existing.isCustom && edited.length > 0) { set.overriddenFields = [ ...new Set([...existing.overriddenFields, ...edited]), diff --git a/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx b/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx index 4101dd8..4baba9c 100644 --- a/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx +++ b/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx @@ -127,6 +127,25 @@ export function ModFormDialog({ } /> +
+ Aliases players actually search by that don't appear in the + title - e.g. "wimf" for "What's in my Fool". Matched alongside + the title/id in the catalog search box above; never synced from + or overwritten by the upstream index. +
+Loading…
) : (