Files
website/server/db/seed.js
wtclaude 1d4cd4adae
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m3s
feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled
before any code that land in core rather than in module-uo. Pairs with
Module-uo#22 and docs#194.

## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff`

Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone,
uo.world.saved) are described as admin-audience everywhere, and the narrowest
value the lattice had was `staff` -- which ceilings.js defines as admin, editor
AND moderator. Ceilinging them there would have let an operator save a rule that
mails the staff audit digest to every moderator in it.

`admin` is the ONLY genuine refinement in the tree -- every admin is staff, which
is exactly the containment every other pair of branches lacks -- so it is a child
rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the
new PARENT entry.

**The one non-obvious consequence, and the reason for ROLE_CEILINGS.**
notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was
correct while `staff` was the only role-gated value, and the day `admin` arrived
it would have silently published every admin-ceilinged id -- the staff audit
digest, the economy thresholds -- to every player's preferences screen by name.
It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry
fails closed instead. An EDITOR is the viewer that tells the two rules apart, and
the new tests use one.

MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid
under 1.7.0 is valid now and no stored value changes.

## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle

`news.post` has been a declared payload contract with no caller since Phase 2, so
a rule naming it could never fire. utils/newsNotify.js is the caller;
announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on
the same enqueueIfNeeded job id -- the single "newly published news" transition
signal, not re-derived.

**News push therefore stops on upgrade** until an operator enables the seeded
rule. That is the org lead's decision, taken over keeping the raw call beside the
emit "for one release": an exception with a deadline nobody owns, which Phase 6
already refused for Teams. The Rules screen gains a second migration notice
naming news, and Phase 13's release note carries it as an upgrade step.

**The seed needed its own one-shot key, and this is the trap worth recording.**
`engagement_team_rules_seeded` is already stamped on every deployment that has
booted since Phase 6, and the guard reads its presence -- so appending news to
RULES would have seeded it on fresh installs only, and on exactly the upgrades
that lose their raw push, never. One key per seed GROUP is now the rule;
seedGroup() is the shared implementation and seedCoreRules() is what boot calls.

Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path
App.jsx does not mount. An example is what the template editor previews and
test-sends with, so a wrong one is a preview that looks right and a mail that is
not. It is `/site/news`, the list, which is what the Discord and town-crier
announcements have always linked.

1550 tests pass (16 new), 327 client tests pass, client builds, check:modules
clean -- core still names no module identifier with module-uo now registering 24
UO-named triggers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:33:02 -05:00

121 lines
5.7 KiB
JavaScript

require('dotenv').config()
const settingsDb = require('../src/model/settings/settings.db')
const wikiDb = require('../src/model/wiki/wiki.db')
const users = require('../src/model/users/users.model')
const { ensureSchema, close } = require('../src/utils/db')
const { seedTemplates } = require('../src/engagement/templates')
const { seedCoreRules } = require('../src/engagement/coreRules')
const brand = require('../src/config/brand')
const log = require('../src/utils/logger')('seed')
// Default settings — only inserted if the key does not already exist.
const DEFAULT_SETTINGS = {
site_mode: 'maintenance', // start safe: site is in maintenance until set live
site_mode_changed_at: '',
site_mode_changed_by: '',
maintenance_message:
`${brand.shortName} is being shaped beneath a midnight sky. The site will return soon.`,
status_message: 'In progress.',
homepage_teaser:
`${brand.shortName} is still being shaped beneath a midnight sky. A quiet preview for ` +
'future news, screenshots, guides, and community notes as the world comes online.',
contact_email: brand.contactEmail,
site_title: brand.name,
// Android App Links opt-in — off until an admin enables it (docs/android/APP_LINKS.md).
mobile_app_links_enabled: 'false',
// Hosts a module may be installed from (MODULE_SYSTEM.md §2.7.2 decision 6).
//
// The environment BOOTSTRAPS this and does not own it: seedDefault is an
// INSERT IGNORE, so the variable supplies a sane default on a fresh install
// and never reaches back in to overwrite what an admin later chose in
// Admin → Modules. Changing MODULE_SOURCE_HOSTS on an existing deployment is
// therefore a no-op, which is the intended behaviour and not an oversight.
//
// An empty stored value forbids every install rather than allowing every host
// — the safe direction for a setting someone might blank by accident.
module_source_hosts: process.env.MODULE_SOURCE_HOSTS || 'gitea.whitlocktech.com',
}
// Starter wiki sections (editable later via the admin panel).
//
// The SLUGS are deliberately untouched by the de-UO pass: `seedDefault*` only
// inserts a row that is not already there, so renaming one adds a duplicate page
// to every existing install rather than renaming anything.
// [slug, title, description, sort_order]
const WIKI_CATEGORIES = [
['guides', 'Guides', 'Getting started and how-to guides.', 10],
['world', 'World & Lore', `Regions, maps, and the story of ${brand.shortName}.`, 20],
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
['community', 'Community & Rules', 'Player conduct and server policies.', 40],
]
// The 8 starter pages, each mapped to a section. [slug, title, body, categorySlug]
const WIKI_PAGES = [
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.', 'guides'],
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.', 'world'],
['lore', 'Lore', 'Stories, places, factions, and mysteries.', 'world'],
['systems', 'Server Systems', 'Server mechanics and custom features.', 'gameplay'],
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.', 'gameplay'],
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', 'gameplay'],
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.', 'gameplay'],
['rules', 'Rules', 'Player conduct, server expectations, and policies.', 'community'],
]
async function seedDefaults() {
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
await settingsDb.seedDefault(key, value)
}
for (const [slug, title, description, sortOrder] of WIKI_CATEGORIES) {
await wikiDb.seedDefaultCategory(slug, title, description, sortOrder)
}
for (const [slug, title, body, categorySlug] of WIKI_PAGES) {
await wikiDb.seedDefault(slug, title, body)
// Attach to its section (only if not already categorized — safe re-run /
// migration of pages seeded before the wiki upgrade).
await wikiDb.assignCategoryBySlug(slug, categorySlug)
}
// The shipped mail bodies (ENGAGEMENT.md §4.6.1). Idempotent, and it never
// overwrites a row an operator has edited — `customized = 1` is checked in the
// UPDATE's own WHERE, not in a read-then-write. Never throws: a template that
// failed to seed costs the shipped default, which `renderByKey` falls back to
// anyway, and must not stop a boot.
await seedTemplates()
// Core's five rules — the four Team ones (Phase 6) and news (Phase 11) —
// seeded ONCE and all disabled. Each GROUP carries its own settings-key guard
// rather than re-ensured, so a rule an operator deleted stays deleted and one
// they enabled stays enabled; and so the news rule reaches the deployments that
// were already stamped for Teams, which are exactly the ones that lose their
// raw news push to the engine (ENGAGEMENT.md §7.1 Q9).
await seedCoreRules()
log.info('settings and wiki defaults ensured')
}
// Create the first admin from env vars, only when no users exist yet.
async function createInitialAdminFromEnv() {
const { ADMIN_USERNAME, ADMIN_PASSWORD } = process.env
if (!ADMIN_USERNAME || !ADMIN_PASSWORD) return
if ((await users.count()) > 0) return
await users.createUser({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD, role: 'admin' })
log.info(`created initial admin "${ADMIN_USERNAME}"`)
}
// Allow running standalone: `npm run seed`
if (require.main === module) {
;(async () => {
try {
await ensureSchema()
await seedDefaults()
await createInitialAdminFromEnv()
} catch (err) {
log.error('seed failed', err)
process.exitCode = 1
} finally {
await close()
}
})()
}
module.exports = { seedDefaults, createInitialAdminFromEnv }