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>
This commit is contained in:
@@ -49,8 +49,15 @@ const TRIGGERS = [
|
||||
description: 'A plain-text summary, already stripped of markup.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
|
||||
description: 'The post category, when it has one.' },
|
||||
{ name: 'postUrl', type: 'url', required: true, example: '/news/five-on-friday-yew-invasion',
|
||||
description: 'Site-relative path to the post.' },
|
||||
// **`/site/news`, the LIST, and not a per-post path.** The example said
|
||||
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
|
||||
// one and the path turned out not to exist — `App.jsx` mounts `/site/news`
|
||||
// and nothing under it, which is why `announceJobs.logic.js` links the list
|
||||
// from the Discord and town-crier announcements too. An `example` is what
|
||||
// the template editor previews and test-sends with (§4.3 property 3), so an
|
||||
// example naming a 404 is a preview that looks right and a mail that is not.
|
||||
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
|
||||
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── Resolving a rule's audience to recipients ──────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
|
||||
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`,
|
||||
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
|
||||
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
|
||||
// an **`audience_segment_id`** pointing at an operator-composed tree of
|
||||
// module-declared audiences (segments.js). This file turns either into user ids.
|
||||
@@ -88,9 +88,14 @@ async function resolveForRule(rule, event) {
|
||||
}
|
||||
}
|
||||
case 'staff':
|
||||
case 'admin':
|
||||
// Both role-gated, and resolved through the ONE query rather than two.
|
||||
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
|
||||
// third is added the resolver does not need a third case — and, more to
|
||||
// the point, cannot get one of them wrong while the others stay right.
|
||||
return {
|
||||
userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES),
|
||||
ceiling: 'staff',
|
||||
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
|
||||
ceiling: rule.audience,
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ── The four Team rules core ships, all of them OFF ────────────────────────
|
||||
// ── The five rules core ships, all of them OFF ─────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
|
||||
// people with no operator configuration at all: the code decided who was mailed
|
||||
@@ -24,6 +24,17 @@
|
||||
// that has seen this seed never sees it again — deleted rules stay deleted, and
|
||||
// an enabled rule stays enabled rather than being reset to off.
|
||||
|
||||
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
|
||||
// rather than an entry in the list above.** The Team key is already stamped on
|
||||
// every deployment that has booted since Phase 6, and the guard reads its
|
||||
// presence — so appending to `RULES` would have seeded the news rule on fresh
|
||||
// installs only, and on exactly the upgrades that need it, never. Those are the
|
||||
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
|
||||
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
|
||||
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
|
||||
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
|
||||
// existing group is a rule that only fresh installs will ever see.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
@@ -32,6 +43,9 @@ const log = require('../utils/logger')('engagement')
|
||||
// the settings table can tell when it ran; only its presence is read.
|
||||
const SEEDED_KEY = 'engagement_team_rules_seeded'
|
||||
|
||||
// Phase 11's, and separate for the reason above. Same shape, same semantics.
|
||||
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
trigger_id: 'team.forum.post',
|
||||
@@ -98,20 +112,57 @@ const RULES = [
|
||||
},
|
||||
]
|
||||
|
||||
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
|
||||
const NEWS_RULES = [
|
||||
{
|
||||
trigger_id: 'news.post',
|
||||
name: 'News posts',
|
||||
// `subscribers`, which is the trigger's declared default and the population
|
||||
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
|
||||
// opted into this id on at least one channel. Not `authenticated`, even
|
||||
// though the trigger's ceiling permits it — a news post is worth telling
|
||||
// people who asked to be told, and mailing the whole user table on every
|
||||
// publish is how a notification feature earns a spam complaint.
|
||||
audience: 'subscribers',
|
||||
// **All three channels, unlike the Team rules' `email` alone**, and that is
|
||||
// the continuity half of §7.1 Q9's answer. Push is on this rule because push
|
||||
// is what the raw tickle did; leaving it off would mean an operator who
|
||||
// enabled the rule to restore news push got mail instead. In-app rides along
|
||||
// because the inbox is the surface a tickle deep-links into (Phase 7).
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
// The generic body plus the structural projection (§4.6.1 property 1):
|
||||
// `news.post` declares its own `title` and `postUrl`, which the projection
|
||||
// leaves exactly as emitted, so an unauthored mail already names the post and
|
||||
// links it. `inapp.event` is the in-app renderer's; push carries no content
|
||||
// by construction and needs no template.
|
||||
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
|
||||
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
|
||||
// subject is the recipient. "Do not tell me about news more than once an
|
||||
// hour" is the useful rule; keying it per post would make it a no-op, since
|
||||
// every post is a new subject.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Seed the four rules, once. Returns a small summary for the boot log.
|
||||
* Seed one group of rules, once, under its own guard key.
|
||||
*
|
||||
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
|
||||
* failed to seed costs an operator one visit to the "new rule" form, not a
|
||||
* deployment.
|
||||
*
|
||||
* @param {string} key the one-shot settings guard for THIS group
|
||||
* @param {object[]} rules
|
||||
* @param {string} note what the boot log should say when it inserts
|
||||
*/
|
||||
async function seedTeamRules() {
|
||||
async function seedGroup(key, rules, note) {
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
try {
|
||||
const seen = await settingsDb.get(SEEDED_KEY)
|
||||
if (seen) return { ...summary, skipped: RULES.length }
|
||||
const seen = await settingsDb.get(key)
|
||||
if (seen) return { ...summary, skipped: rules.length }
|
||||
|
||||
for (const rule of RULES) {
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await rulesDb.insert({
|
||||
audience_segment_id: null,
|
||||
@@ -133,17 +184,46 @@ async function seedTeamRules() {
|
||||
// Stamped even on a partial run. Re-running would duplicate the rules that
|
||||
// did insert, and a duplicate rule is two mails per event — a worse outcome
|
||||
// than the one missing rule an operator can add from the screen.
|
||||
await settingsDb.set(SEEDED_KEY, new Date().toISOString())
|
||||
await settingsDb.set(key, new Date().toISOString())
|
||||
if (summary.inserted) {
|
||||
log.info('seeded Team engagement rules, all disabled', {
|
||||
rules: summary.inserted,
|
||||
note: 'Team email stays off until an operator enables one',
|
||||
})
|
||||
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('team rule seeding failed', { message: err.message })
|
||||
log.error('rule seeding failed', { key, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { seedTeamRules, RULES, SEEDED_KEY }
|
||||
/** The four Team rules (Phase 6). */
|
||||
const seedTeamRules = () =>
|
||||
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
|
||||
|
||||
/** The one news rule (Phase 11). */
|
||||
const seedNewsRule = () =>
|
||||
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
|
||||
|
||||
/**
|
||||
* Both groups, which is what the boot path calls.
|
||||
*
|
||||
* Sequential rather than concurrent, and not for correctness — each group has its
|
||||
* own guard key and its own rows — but so the boot log reads in a fixed order and
|
||||
* a failure names one group rather than an interleaving of two.
|
||||
*/
|
||||
async function seedCoreRules() {
|
||||
const team = await seedTeamRules()
|
||||
const news = await seedNewsRule()
|
||||
return {
|
||||
inserted: team.inserted + news.inserted,
|
||||
skipped: team.skipped + news.skipped,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedCoreRules,
|
||||
seedTeamRules,
|
||||
seedNewsRule,
|
||||
RULES,
|
||||
NEWS_RULES,
|
||||
SEEDED_KEY,
|
||||
NEWS_SEEDED_KEY,
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -20,16 +20,29 @@
|
||||
// ├── subscribers logged-in users who opted into this id
|
||||
// ├── members a module-declared list (a Team, the governors)
|
||||
// ├── staff admin / editor / moderator
|
||||
// │ └── admin admins only
|
||||
// └── owner the one user the event is about
|
||||
//
|
||||
// The four leaves are mutually INCOMPARABLE, deliberately. `owner` is not a
|
||||
// The four branches are mutually INCOMPARABLE, deliberately. `owner` is not a
|
||||
// subset of `subscribers` (an owner need not have subscribed), `staff` is not a
|
||||
// subset of `members`, and no pair of them has a common descendant. That is what
|
||||
// makes `meet()` below return null rather than guessing, and a null meet is a
|
||||
// refused save (§5.1a rule 3) rather than a silent widening.
|
||||
//
|
||||
// **`admin` was added in Phase 11 and it is the one genuine refinement in the
|
||||
// tree.** Phase 2 shipped six values, and §8.6 then turned out to describe three
|
||||
// triggers as admin-audience — `uo.audit.staff_action`, `uo.economy.milestone`,
|
||||
// `uo.world.saved` — for which the narrowest available value was `staff`, i.e.
|
||||
// admin / editor / moderator. Ceilinging them there would have permitted a rule
|
||||
// that mails the staff audit digest to every editor, which is the same class of
|
||||
// mistake the whole file exists to prevent. It is a CHILD rather than a seventh
|
||||
// leaf because every admin is staff — the containment the other pairs lack — and
|
||||
// that is why `permits`, `meet` and `meetAll` needed no change at all beyond the
|
||||
// new `PARENT` entry. It is a module-contract change (a module may now declare
|
||||
// `ceiling: 'admin'`) and took MODULE_API_VERSION to 1.8.0.
|
||||
//
|
||||
// Nothing here reaches the database, the network or a user record. It is
|
||||
// arithmetic over six constants, so it is safe to require anywhere.
|
||||
// arithmetic over seven constants, so it is safe to require anywhere.
|
||||
|
||||
// child → parent. A tree, which is what makes `permits` a walk to the root and
|
||||
// `meet` a comparison rather than a search: two nodes in a tree have a greatest
|
||||
@@ -40,6 +53,7 @@ const PARENT = {
|
||||
subscribers: 'authenticated',
|
||||
members: 'authenticated',
|
||||
staff: 'authenticated',
|
||||
admin: 'staff',
|
||||
owner: 'authenticated',
|
||||
}
|
||||
|
||||
@@ -51,6 +65,7 @@ const LABELS = {
|
||||
subscribers: 'Signed-in users subscribed to this event',
|
||||
members: 'Members of a module-declared list',
|
||||
staff: 'Staff only',
|
||||
admin: 'Administrators only',
|
||||
owner: 'Only the user the event is about',
|
||||
}
|
||||
|
||||
@@ -66,9 +81,50 @@ const LABELS = {
|
||||
// TOLD, which is the tier gate's population.
|
||||
const STAFF_CEILING_ROLES = ['admin', 'editor', 'moderator']
|
||||
|
||||
// And which the `admin` ceiling names. One role, and it is written as a list for
|
||||
// the same reason `STAFF_CEILING_ROLES` is: `recipients.staff(roles)` takes a
|
||||
// list, so the two ceilings resolve through one query rather than through two
|
||||
// that could drift.
|
||||
const ADMIN_CEILING_ROLES = ['admin']
|
||||
|
||||
/** Does this user fall inside the `staff` ceiling? */
|
||||
const isStaffRole = (role) => STAFF_CEILING_ROLES.includes(role)
|
||||
|
||||
/** Does this user fall inside the `admin` ceiling? */
|
||||
const isAdminRole = (role) => ADMIN_CEILING_ROLES.includes(role)
|
||||
|
||||
// The ceilings that name a ROLE, and the test for each. Two consumers read this
|
||||
// rather than asking about `staff` by name: the audience resolver, and the
|
||||
// preferences catalog's `visibleTo`.
|
||||
//
|
||||
// **`visibleTo` is why this is a table and not two `if`s.** Its rule is that an
|
||||
// id nobody outside a role can ever be reached by must not appear by name in a
|
||||
// player's preferences screen — `uo.cheat.detected` is the case the lattice was
|
||||
// written for. That rule was expressed as `ceiling !== 'staff'` when `staff` was
|
||||
// the only role-gated value; the day `admin` was added, that spelling would have
|
||||
// silently published every admin-ceilinged id to every player. A table cannot
|
||||
// drift the same way: adding a role-gated ceiling without adding it here is a
|
||||
// registration that fails its own test, not a leak.
|
||||
const ROLE_CEILINGS = {
|
||||
staff: { roles: STAFF_CEILING_ROLES, test: isStaffRole },
|
||||
admin: { roles: ADMIN_CEILING_ROLES, test: isAdminRole },
|
||||
}
|
||||
|
||||
/** Is this ceiling one that only certain roles can ever be reached by? */
|
||||
const isRoleCeiling = (ceiling) => Object.prototype.hasOwnProperty.call(ROLE_CEILINGS, ceiling)
|
||||
|
||||
/**
|
||||
* Could a viewer with this role EVER be reached by an id ceilinged here?
|
||||
*
|
||||
* The question a catalog asks before offering a toggle, and it fails closed: a
|
||||
* role-gated ceiling with no role, or an unknown role, is a no.
|
||||
*/
|
||||
function reachableBy(ceiling, role) {
|
||||
const gate = ROLE_CEILINGS[ceiling]
|
||||
if (!gate) return true
|
||||
return gate.test(role)
|
||||
}
|
||||
|
||||
const CEILINGS = Object.keys(PARENT)
|
||||
|
||||
/** Is this one of the six? The gate every registration and every rule save runs. */
|
||||
@@ -119,4 +175,18 @@ function meetAll(list) {
|
||||
return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0])
|
||||
}
|
||||
|
||||
module.exports = { CEILINGS, LABELS, STAFF_CEILING_ROLES, isStaffRole, isCeiling, permits, meet, meetAll }
|
||||
module.exports = {
|
||||
CEILINGS,
|
||||
LABELS,
|
||||
STAFF_CEILING_ROLES,
|
||||
ADMIN_CEILING_ROLES,
|
||||
ROLE_CEILINGS,
|
||||
isStaffRole,
|
||||
isAdminRole,
|
||||
isRoleCeiling,
|
||||
reachableBy,
|
||||
isCeiling,
|
||||
permits,
|
||||
meet,
|
||||
meetAll,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,26 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.8.0 - a seventh value in the audience ceiling lattice: `admin`, a child of
|
||||
// `staff` (docs/website/ENGAGEMENT.md Phase 11, decision 1). A module may now
|
||||
// declare `ceiling: 'admin'` on a trigger or an audience, so the set of values
|
||||
// `registerEventTriggers` and `registerAudiences` accept grew. Additions only,
|
||||
// so minor: every declaration valid before is valid now, no stored value
|
||||
// changes, and module-uo's `coreApi: "^1.3.0"` still resolves.
|
||||
//
|
||||
// It exists because Phase 11's operator-facing triggers - `uo.audit.staff_action`,
|
||||
// `uo.economy.milestone`, `uo.world.saved` - are described everywhere as
|
||||
// admin-audience, and the narrowest value the lattice had was `staff`, which
|
||||
// means admin / editor / moderator. Ceilinging them there would have permitted a
|
||||
// rule that mails the staff audit digest to every editor.
|
||||
//
|
||||
// **What a module has to know about it beyond the new name.** `admin` is the one
|
||||
// pair in the tree with real containment - every admin is staff - so it is the
|
||||
// only place `permits` is true between two non-`authenticated` values:
|
||||
// `permits('staff', 'admin')` holds and nothing else of that shape does. A
|
||||
// trigger ceilinged `staff` therefore accepts an `admin` audience, which is the
|
||||
// intended narrowing, and the reverse is refused as it should be.
|
||||
|
||||
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2).
|
||||
// Additions only, so minor: `api.registerEventTriggers([...])`,
|
||||
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
|
||||
@@ -81,6 +101,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.7.0'
|
||||
const MODULE_API_VERSION = '1.8.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -12,12 +12,12 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
|
||||
const emailDedupe = require('../../../model/emailDedupe/emailDedupe.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
||||
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
||||
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
|
||||
const newsEmit = require('../../../utils/newsNotify')
|
||||
const htmlShell = require('../../../utils/htmlShell')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
@@ -47,13 +47,36 @@ async function announceIfNewlyPublished(post, transition) {
|
||||
// sidecar hiccup cannot break saving a post: the same guarantee the enqueue
|
||||
// above gives.
|
||||
await registries.dispatchPostHook('onSaved', { post, transition })
|
||||
// Opt-in push tickle to news.post subscribers, on the same transition.
|
||||
// Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving.
|
||||
if (jobId) {
|
||||
Promise.resolve(pushDispatch.publish('news.post', { ref: String(post.id) })).catch((err) =>
|
||||
log.warn('news push failed', { postId: post.id, message: err.message }),
|
||||
)
|
||||
}
|
||||
// **The engagement engine, and it REPLACES the raw push tickle that used to be
|
||||
// here** (ENGAGEMENT.md §7.1 Q9, decided 2026-08-31 at the start of Phase 11).
|
||||
//
|
||||
// `news.post` has been a declared payload contract with no caller since Phase 2
|
||||
// — a rule naming it could never fire — so on a real deployment the only mail
|
||||
// or inbox item a rule could produce came from Teams. This is the call that
|
||||
// fixes that, and it is deliberately the only thing about this function that
|
||||
// changed: the announce legs above (a one-shot DELIVERY to a channel of the
|
||||
// deployment, with retry) and the post hooks (idempotent STATE mirroring, which
|
||||
// also runs on delete) are different KINDS of thing and both still fire exactly
|
||||
// as they did. `registries.js` already states those two apart; this adds a third
|
||||
// distinction of the same kind rather than replacing either.
|
||||
//
|
||||
// **What it replaced, and what that costs.** `pushDispatch.publish('news.post',
|
||||
// …)` stood here and tickled every subscriber directly. It is gone, so push now
|
||||
// rides the engine like every other channel — which means it goes nowhere until
|
||||
// an operator enables the `news.post` rule core seeds `enabled = 0` beside the
|
||||
// four Team ones (engagement/coreRules.js). That IS a behaviour change on
|
||||
// upgrade and it is the org lead's decision, taken over keeping the raw call
|
||||
// beside the emit "for one release": that is an exception with a deadline
|
||||
// nobody owns, and Phase 6 refused the analogous carve-out for Teams. The admin
|
||||
// Rules screen says so, and the release note names it.
|
||||
//
|
||||
// **Gated on `jobId`, the same value the push was gated on.** That is the single
|
||||
// "newly published news" transition test and re-deriving it here would be a
|
||||
// second chance to disagree with the first — an edit or a re-publish must not
|
||||
// re-fire. Fire-and-forget, like everything else in this function: `emit` does
|
||||
// not await delivery by design, and a rule lookup must not be able to fail
|
||||
// saving a post.
|
||||
if (jobId) newsEmit.emitNewsPost(post)
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
|
||||
112
server/src/utils/newsNotify.js
Normal file
112
server/src/utils/newsNotify.js
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/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 }
|
||||
Reference in New Issue
Block a user