Registers the engagement set R7 put in v1: thirteen triggers, four push streams, three audiences, four bodies (two triggers, email and in-app) and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68). The raid alert goes to everyone authorised on the tool cupboard, one emit per linked person with ownerUserId, so the owner ceiling holds per emit. It covers doors and walls (protocol 7), never names the raider, alerts nobody when there is no cupboard, and carries ownerOnline so "offline only" is the seeded rule's condition rather than code. The fan-out runs off ingest before a frame is applied, since applying a disband deletes the roster the notice is sent to. A replayed event is told only while it is news: 15 minutes for broadcasts, 24 hours for personal and staff events. Dedupe keys come from the event, not the sidecar's row id. Server online/offline and a new kills leader are in-memory transitions, never on first sight, and a tie is not a lead. A login with no approval within a minute becomes a staff notice via a query, so a restart loses nothing. Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game that hung, or whose bridge was unloaded, while the sidecar stayed up no longer reads as online. It stops naming players as online, and a stale board no longer moves "last seen". engagement-triggers.json is the committed freeze of all of it, checked in CI with line endings normalised. The check was verified by breaking it both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
178 lines
6.5 KiB
JavaScript
178 lines
6.5 KiB
JavaScript
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
|
//
|
|
// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its
|
|
// keep). Raw parameterised SQL through `core.query`, placeholders always.
|
|
|
|
const core = require('../../core')
|
|
|
|
const LINKS = 'rust_account_links'
|
|
const PLAYERS = 'rust_players'
|
|
const STATS = 'rust_player_wipe_stats'
|
|
|
|
/**
|
|
* The link for one Steam id, or undefined.
|
|
*
|
|
* Joins core's `users` for the username, because every caller that asks "who
|
|
* owns this?" wants a name rather than an integer — and the one caller that
|
|
* refuses a re-link has to be able to say *whose* it is.
|
|
*/
|
|
async function getBySteamId(steamId) {
|
|
const rows = await core.query(
|
|
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
|
l.linked_at AS linkedAt, u.username
|
|
FROM ${LINKS} l
|
|
JOIN users u ON u.id = l.user_id
|
|
WHERE l.steam_id = ?`,
|
|
[steamId],
|
|
)
|
|
return rows[0]
|
|
}
|
|
|
|
/**
|
|
* Every Steam account one website user holds, newest first.
|
|
*
|
|
* **It joins `rust_players` for the name the game last saw**, and that is not a
|
|
* convenience. The name on the LINK is what the player was called at the moment
|
|
* they linked, which is a Rust name and changes on a whim — so a player who has
|
|
* renamed since sees a name they no longer use, on the one page of the site that
|
|
* is about who they are. The admin panel already preferred the newer one; this
|
|
* is the same rule applied where the person themselves is reading.
|
|
*
|
|
* A LEFT JOIN, because a player can link an account and never play on it.
|
|
*/
|
|
async function listForUser(userId) {
|
|
return core.query(
|
|
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
|
l.linked_at AS linkedAt, p.name AS playerName
|
|
FROM ${LINKS} l
|
|
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
|
WHERE l.user_id = ?
|
|
ORDER BY l.linked_at DESC`,
|
|
[userId],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Record a link.
|
|
*
|
|
* **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in
|
|
* SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one
|
|
* website account to another — which, once phase 7 makes a link a privilege path
|
|
* and phase 13 makes it an entitlement, is an account takeover performed by
|
|
* typing a six-character code. The duplicate-key error is the refusal, and the
|
|
* controller turns it into a sentence.
|
|
*/
|
|
async function insert({ steamId, userId, name, serverId }) {
|
|
await core.query(
|
|
`INSERT INTO ${LINKS} (steam_id, user_id, name, server_id)
|
|
VALUES (?, ?, ?, ?)`,
|
|
[steamId, userId, name || null, serverId || null],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Remove a link the caller owns.
|
|
*
|
|
* Scoped by `user_id` in the statement rather than checked before it: a delete
|
|
* that reads, decides, then writes has a gap between the read and the write, and
|
|
* this way the ownership test and the deletion are the same operation. Answers
|
|
* how many rows went, so a caller can tell "removed" from "was not yours".
|
|
*/
|
|
async function removeOwned(steamId, userId) {
|
|
const result = await core.query(
|
|
`DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`,
|
|
[steamId, userId],
|
|
)
|
|
return Number(result && result.affectedRows) || 0
|
|
}
|
|
|
|
/**
|
|
* Remove a link whoever holds it — the in-game `/unlink` path, and the staff
|
|
* unlink on the `admin.users.detail` panel (D25).
|
|
*
|
|
* Unscoped by user on purpose: neither caller is the link's owner and both have
|
|
* already established their authority another way. In game the authority is the
|
|
* Steam account itself — whoever is connected as it is who it is; on the admin
|
|
* panel it is the tier gate. Which is why the admin caller writes an
|
|
* `activity.log` entry naming the operator and this does not: it cannot tell the
|
|
* two apart, and a log line that guessed would be worse than none.
|
|
*/
|
|
async function removeBySteamId(steamId) {
|
|
const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId])
|
|
return Number(result && result.affectedRows) || 0
|
|
}
|
|
|
|
/**
|
|
* Every link one user holds, enriched with what this module knows about that
|
|
* player — for the `admin.users.detail` panel.
|
|
*
|
|
* A LEFT JOIN, because a player can link an account and never play on it. An
|
|
* operator looking at that user should see the link, not an empty panel.
|
|
*/
|
|
async function listForUserWithPlayer(userId) {
|
|
return core.query(
|
|
`SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt,
|
|
p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen
|
|
FROM ${LINKS} l
|
|
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
|
WHERE l.user_id = ?
|
|
ORDER BY l.linked_at DESC`,
|
|
[userId],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Per-server all-time totals for one Steam id.
|
|
*
|
|
* The same rows the public leaderboard sums, grouped by server instead of
|
|
* filtered to one — so an operator sees a player across the fleet in one read.
|
|
* All-time, deliberately: an admin looking at a user wants their history, not
|
|
* this week's.
|
|
*/
|
|
async function statsForSteamId(steamId) {
|
|
return core.query(
|
|
`SELECT s.server_id AS serverId, srv.name AS serverName,
|
|
SUM(s.kills) AS kills,
|
|
SUM(s.deaths) AS deaths,
|
|
SUM(s.npc_kills) AS npcKills,
|
|
SUM(s.structures) AS structures,
|
|
SUM(s.playtime_sec) AS playtimeSec,
|
|
MAX(s.last_seen) AS lastSeen,
|
|
COUNT(DISTINCT s.wipe_id) AS wipes
|
|
FROM ${STATS} s
|
|
LEFT JOIN rust_servers srv ON srv.id = s.server_id
|
|
WHERE s.steam_id = ?
|
|
GROUP BY s.server_id, srv.name
|
|
ORDER BY SUM(s.playtime_sec) DESC`,
|
|
[steamId],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Which of these Steam ids are linked, and to whom.
|
|
*
|
|
* The one question every notification asks — "who on the website is this
|
|
* player?" — asked for a set at once, because a raid names a cupboard's whole
|
|
* authorisation list and a clan event a whole roster. An unlinked id is simply
|
|
* absent from the answer: there is nobody to tell.
|
|
*/
|
|
async function userIdsForSteamIds(steamIds) {
|
|
if (!steamIds.length) return []
|
|
const marks = steamIds.map(() => '?').join(', ')
|
|
return core.query(
|
|
`SELECT steam_id AS steamId, user_id AS userId FROM ${LINKS} WHERE steam_id IN (${marks})`,
|
|
steamIds,
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
getBySteamId,
|
|
listForUser,
|
|
listForUserWithPlayer,
|
|
insert,
|
|
removeOwned,
|
|
removeBySteamId,
|
|
statsForSteamId,
|
|
userIdsForSteamIds,
|
|
}
|