feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
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

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>
This commit is contained in:
2026-08-31 20:33:02 -05:00
parent 49a61fdafa
commit 1d4cd4adae
17 changed files with 669 additions and 56 deletions

View File

@@ -0,0 +1,112 @@
// ── The `news.post` emitter (ENGAGEMENT.md §7.1 Q9, Phase 11) ──────────────
//
// Core's own trigger, and until this phase the only one of its five with no
// caller at all: `config/coreTriggers.js` declared the payload contract in Phase
// 2 and said in as many words that nothing emitted yet, and Phase 6 migrated
// only the four `team.*` ones onto the engine. So an operator could write a rule
// on `news.post` and it could never fire.
//
// It is one call, and every line of care here is about what it must NOT disturb.
// `announceIfNewlyPublished` fans one publish three ways now, and they are
// different in kind:
//
// • `announceJobs.enqueueIfNeeded` — a one-shot DELIVERY to a channel of the
// deployment (the in-game town crier, Discord #news), with retry and
// classification. Not per-person. Not the engine's.
// • `registries.dispatchPostHook('onSaved')` — idempotent STATE mirroring,
// which also runs on delete and refreshes silently on an edit. Not the
// engine's either.
// • this — a PER-PERSON notification, subject to a rule, a preference, a
// suppression and a channel. The engine's, and the only one that was missing.
//
// A module keeps both of its doors onto a publish (the announce leg and the post
// hook) and gains no third: `news.post` is core's id, `ctx.events.emit` binds the
// owner at the call and never reads it from the arguments, and §7.2's one
// namespace means an id has exactly one owner across both facets. A module that
// wants a person-facing notification of its own declares its own trigger.
//
// **Nothing here throws.** It is called from a path that has already saved the
// post; a notification is a courtesy and a courtesy that can fail the write
// behind it is a defect. Same posture as `teamNotify.js`, for the same reason.
const engagementEmit = require('./engagementEmit')
const log = require('./logger')('news-notify')
// How much of a post body the mail carries when the post has no excerpt of its
// own. Same length as the Team excerpt: long enough to tell whether it is worth
// opening, short enough that the mail is not a copy of the article.
const EXCERPT_CHARS = 200
// **The site has no per-post route.** `App.jsx` mounts `/site/news` (the list)
// and nothing under it, which is why `announceJobs.logic.js` links the list from
// the Discord and town-crier announcements too. So the mail links what exists.
// Declared `required: true` on the trigger, so this is a constant rather than an
// optional — a variable that is sometimes absent is a template that sometimes
// renders a dead button.
//
// Site-RELATIVE, because `engagementEmit.RELATIVE_URL` validates `url` variables
// that way: a payload value that ends up in an href must not be able to carry an
// absolute one somewhere else. The mail renderer absolutizes it against the
// deployment's base.
const NEWS_PATH = '/site/news'
/** Markup out, whitespace collapsed, truncated. The mail is plain text. */
function excerptFrom(post) {
const source = post.excerpt || post.body || ''
const text = String(source)
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim()
if (!text) return null
return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text
}
/**
* Emit `news.post` for a post that has just transitioned into published news.
*
* The caller gates on `announceJobs.enqueueIfNeeded`'s job id — the single
* transition signal — so this does not re-derive it. Passing a post that did not
* transition would produce a duplicate mail, which is why this function does not
* take the transition and cannot be tempted to read it differently.
*
* @param {object} post the saved post row
* @returns {boolean} whether the emit was accepted (for tests; callers ignore it)
*/
function emitNewsPost(post) {
try {
if (!post || post.id == null) return false
const excerpt = excerptFrom(post)
const result = engagementEmit.emit('core', 'news.post', {
data: {
title: post.title || 'A new post',
// Omitted rather than sent empty when the post has neither an excerpt nor
// a body worth quoting. `excerpt` is `required: false`, and an absent
// optional renders as absent; an empty string renders as a blank line
// where a summary should be.
...(excerpt ? { excerpt } : {}),
...(post.category ? { category: String(post.category) } : {}),
postUrl: NEWS_PATH,
},
// **The cooldown subject is the USER, not the post**, which is why there is
// no `subject` here and no `subjectKey` on the declaration. "Do not mail me
// about news more than once an hour" is the useful rule; keying it per post
// would make every cooldown a no-op, since every post is a new subject.
//
// `dedupeKey` IS per post, and that is the other half of the same thought:
// the engine must not write two outbox rows for one publish if this is ever
// reached twice, and the post id is the only stable name for "this publish".
dedupeKey: `news.post:${post.id}`,
})
return Boolean(result && result.ok)
} catch (err) {
log.warn('news event not emitted', { postId: post && post.id, message: err.message })
return false
}
}
module.exports = { emitNewsPost, excerptFrom, NEWS_PATH, EXCERPT_CHARS }