feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -86,9 +86,13 @@ jobs:
|
||||
- name: Build client
|
||||
run: npm run build --prefix client
|
||||
|
||||
bot-install:
|
||||
# No tests/build to run; a clean install still catches a broken or
|
||||
# out-of-sync lockfile before it ships in the bot image.
|
||||
bot-tests:
|
||||
# The install still runs first and still catches a broken or out-of-sync
|
||||
# lockfile before it ships in the bot image — that was this job's whole
|
||||
# purpose until phase 7 (TEAMS.md §7.1) put real logic in the bot: it now
|
||||
# pulls slash-command definitions from the app, merges them into the
|
||||
# whole-set PUT, and runs the defer→dispatch→edit path. None of that is
|
||||
# reachable from the server suite, and phases 8 and 9 add more of it.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -99,3 +103,7 @@ jobs:
|
||||
cache-dependency-path: bot/package-lock.json
|
||||
- name: Install bot deps
|
||||
run: npm ci --prefix bot
|
||||
- name: Run bot tests
|
||||
# Node's built-in runner, no browser and no Discord connection — the
|
||||
# interaction is a fake that records what was called on it.
|
||||
run: npm test --prefix bot
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"keywords": ["discord", "discord.js"],
|
||||
|
||||
@@ -5,6 +5,7 @@ const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
|
||||
|
||||
const createLogger = require('../utils/logger')
|
||||
const commands = require('./commands')
|
||||
const dynamicCommands = require('./dynamicCommands')
|
||||
const messageFilter = require('./messageFilter')
|
||||
const scheduler = require('../scheduler/scheduler')
|
||||
const roleMenuHandler = require('./roleMenuHandler')
|
||||
@@ -22,12 +23,46 @@ let status = 'disconnected' // disconnected | connecting | connected | error
|
||||
let statusDetail = null
|
||||
let lastConnectedAt = null
|
||||
|
||||
// One whole-set PUT of the bot's own commands plus whatever the app has
|
||||
// registered (TEAMS.md §7.1). Because it replaces the set rather than adding to
|
||||
// it, DEREGISTRATION is free: a module that is gone is simply absent from the
|
||||
// next pull, and nobody has to remember to take its command back.
|
||||
async function registerCommands(applicationId, targetGuildId) {
|
||||
const dynamic = dynamicCommands.definitions()
|
||||
const rest = new REST({ version: '10' }).setToken(client.token)
|
||||
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
|
||||
body: commands.all.map((c) => c.data),
|
||||
body: [...commands.all.map((c) => c.data), ...dynamic],
|
||||
})
|
||||
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||
log.info('registered guild slash commands', {
|
||||
guildId: targetGuildId,
|
||||
builtIn: commands.all.length,
|
||||
fromApp: dynamic.length,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-pull the app's commands and re-register the set if it moved.
|
||||
*
|
||||
* Called on `ready` and again whenever the app nudges
|
||||
* (`POST /internal/refresh-commands`). A no-op when nothing changed, so a nudge
|
||||
* per module state change costs one cheap GET rather than a REST.put per
|
||||
* install — and a disconnected bot does nothing at all, since there is no
|
||||
* application to register against until it logs in.
|
||||
*/
|
||||
async function refreshCommands() {
|
||||
const result = await dynamicCommands.pull()
|
||||
if (!result.ok || !result.changed) return result
|
||||
if (!client || !client.isReady()) return result
|
||||
try {
|
||||
await registerCommands(client.application.id, guildId)
|
||||
} catch (err) {
|
||||
// The PUT is all-or-nothing: a definition Discord rejects costs every
|
||||
// command, the built-ins included. Loud, and never fatal to the process.
|
||||
log.error('re-registering slash commands failed — the previous set is still live', {
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
@@ -54,6 +89,11 @@ async function stop() {
|
||||
// failure here leaves the client connected but flags an error status.
|
||||
async function onReady() {
|
||||
try {
|
||||
// Pull BEFORE the single PUT, so the app's commands are in the very first
|
||||
// registration rather than appearing a beat later. The pull never throws —
|
||||
// an unreachable app costs the module commands and nothing else, and the
|
||||
// bot's own set registers exactly as it always did.
|
||||
await dynamicCommands.pull()
|
||||
await registerCommands(client.application.id, guildId)
|
||||
await scheduler.start(client)
|
||||
tempRoleSweeper.start(client)
|
||||
@@ -70,14 +110,19 @@ async function onReady() {
|
||||
}
|
||||
}
|
||||
|
||||
// Route an interaction: role-menu handler first, then chat-input slash commands.
|
||||
// Route an interaction: role-menu handler first, then chat-input slash commands
|
||||
// — the bot's own, then the app's. Built-ins are consulted FIRST and the pull
|
||||
// already drops any module name that collides with one, so the two orderings
|
||||
// agree; checking here as well means a name that somehow reached Discord twice
|
||||
// still runs the bot's version rather than whichever registry answered first.
|
||||
async function onInteractionCreate(interaction) {
|
||||
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const command = commands.get(interaction.commandName)
|
||||
if (!command) return
|
||||
if (!command && !dynamicCommands.has(interaction.commandName)) return
|
||||
try {
|
||||
await command.execute(interaction)
|
||||
if (command) await command.execute(interaction)
|
||||
else await dynamicCommands.execute(interaction)
|
||||
} catch (err) {
|
||||
log.error('command execution failed', { command: interaction.commandName, message: err.message })
|
||||
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
|
||||
@@ -146,4 +191,4 @@ function getConnection() {
|
||||
return { client, guildId }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getStatus, getConnection }
|
||||
module.exports = { start, stop, getStatus, getConnection, refreshCommands }
|
||||
|
||||
242
bot/src/discord/dynamicCommands.js
Normal file
242
bot/src/discord/dynamicCommands.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// Slash commands whose DEFINITION and HANDLER live in the website process
|
||||
// (TEAMS.md §7.1). The bot pulls the definitions, registers them alongside its
|
||||
// own, and executes one by deferring, asking the app, and editing the reply in.
|
||||
//
|
||||
// Everything Discord-specific is here and nothing else is: the app's dispatcher
|
||||
// resolves the actor, enforces access and produces a platform-neutral envelope,
|
||||
// and this file turns that envelope into an interaction reply. A module never
|
||||
// touches an interaction, which is what makes the registration API something a
|
||||
// second platform could implement.
|
||||
const { PermissionFlagsBits } = require('discord.js')
|
||||
|
||||
const appInternal = require('../site/appInternalClient')
|
||||
const staticCommands = require('./commands')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('dynamic-commands')
|
||||
|
||||
// §7.1.1's four types, and the only four. The app rejects anything else at
|
||||
// registration; this map is the second half of that agreement.
|
||||
const OPTION_TYPE = { string: 3, integer: 4, boolean: 5, user: 6 }
|
||||
|
||||
// The pulled set, and the app's module-state counter it came from. `null`
|
||||
// version means "never successfully pulled", which is distinct from 0 ("pulled
|
||||
// while the app had no modules loaded") — the first should retry, the second is
|
||||
// a true answer.
|
||||
let pulled = []
|
||||
let version = null
|
||||
|
||||
/**
|
||||
* Ask the app for the current definitions.
|
||||
*
|
||||
* **A failed pull KEEPS the previous set.** The app being briefly unreachable is
|
||||
* not the same as it having no commands, and treating it as such would
|
||||
* deregister every module command from Discord on a restart blip — then
|
||||
* re-register them a minute later, with members watching commands appear and
|
||||
* disappear. Nothing changes until the app actually answers.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, changed: boolean, count: number}>}
|
||||
*/
|
||||
async function pull() {
|
||||
const res = await appInternal.fetchCommands()
|
||||
if (!res.ok) {
|
||||
log.warn('command pull failed — keeping the set already registered', {
|
||||
error: res.error,
|
||||
holding: pulled.length,
|
||||
})
|
||||
return { ok: false, changed: false, count: pulled.length }
|
||||
}
|
||||
|
||||
const { version: pulledVersion, commands } = res.data || {}
|
||||
const next = Array.isArray(commands) ? commands.filter(usable) : []
|
||||
const changed = version === null || pulledVersion !== version || next.length !== pulled.length
|
||||
pulled = next
|
||||
version = typeof pulledVersion === 'number' ? pulledVersion : 0
|
||||
return { ok: true, changed, count: pulled.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a pulled definition the bot cannot honour.
|
||||
*
|
||||
* **The name collision the app cannot see.** The app validates a command against
|
||||
* everything IT has registered; it does not know the bot's own static array
|
||||
* exists. A module registering `ping` would produce two `ping` entries in one
|
||||
* `REST.put`, which Discord rejects as a batch — taking down every command
|
||||
* including the bot's own. The bot's built-ins win, because they are the ones a
|
||||
* module cannot be asked to change.
|
||||
*/
|
||||
function usable(definition) {
|
||||
if (!definition || typeof definition.name !== 'string') return false
|
||||
if (staticCommands.get(definition.name)) {
|
||||
log.warn('module slash command collides with a built-in and is ignored', {
|
||||
command: definition.name,
|
||||
owner: definition.owner,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The pulled definitions as Discord command data, for the whole-set PUT.
|
||||
*
|
||||
* `access: 'staff'` becomes a Discord-side permission default; `linked` cannot
|
||||
* be expressed in Discord's permission model at all — there is no "has a website
|
||||
* account" predicate — so it is simply not advertised and the app's dispatcher
|
||||
* refuses it. That asymmetry is the reason §7.1 says access is enforced twice
|
||||
* and that only the server half is the gate.
|
||||
*/
|
||||
function definitions() {
|
||||
return pulled.map((c) => {
|
||||
const data = {
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
options: (c.options || []).map((o) => ({
|
||||
name: o.name,
|
||||
description: o.description,
|
||||
type: OPTION_TYPE[o.type],
|
||||
required: Boolean(o.required),
|
||||
...(o.choices ? { choices: o.choices } : {}),
|
||||
})),
|
||||
}
|
||||
if (c.access === 'staff') data.default_member_permissions = PermissionFlagsBits.ModerateMembers.toString()
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
/** Is this a command the app owns? Asked before the static registry is consulted. */
|
||||
const has = (name) => pulled.some((c) => c.name === name)
|
||||
|
||||
// Read the options the member actually supplied, by the names the definition
|
||||
// declared. A `user` option is passed on as the Discord user id and nothing else
|
||||
// — a handler receives platform ids, never a platform object.
|
||||
function collectOptions(interaction, definition) {
|
||||
const out = {}
|
||||
for (const option of definition.options || []) {
|
||||
const supplied = interaction.options.get(option.name)
|
||||
if (supplied === null || supplied === undefined) continue
|
||||
out[option.name] = option.type === 'user' ? String(supplied.value) : supplied.value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// What the caller sees when the app declined. The COPY lives here rather than in
|
||||
// the app on purpose: the app answers with a machine reason, and how a refusal is
|
||||
// phrased to a member is the platform's own voice.
|
||||
function refusal({ reason, access }) {
|
||||
if (reason === 'forbidden' && access === 'linked') {
|
||||
return 'Link your Discord account on the site to use this command.'
|
||||
}
|
||||
if (reason === 'forbidden') return 'You do not have access to that command.'
|
||||
if (reason === 'unknown') return 'That command is no longer available.'
|
||||
return 'Something went wrong running that command.'
|
||||
}
|
||||
|
||||
// Envelope → interaction payload. A response with fields or a title is an embed;
|
||||
// a bare `text` is plain content, which reads better for a one-line answer.
|
||||
function render(envelope) {
|
||||
const { text, title, fields, url } = envelope
|
||||
if (!title && !fields) return { content: text || '' }
|
||||
const embed = {}
|
||||
if (title) embed.title = title
|
||||
if (text) embed.description = text
|
||||
if (url) embed.url = url
|
||||
if (fields) embed.fields = fields
|
||||
return { embeds: [embed] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the envelope at the privacy the HANDLER asked for, not the privacy the
|
||||
* deferral guessed.
|
||||
*
|
||||
* When the two agree — the ordinary case — this is one `editReply`. When the
|
||||
* handler wants a private answer to a publicly deferred command, the deferred
|
||||
* reply is deleted and the answer arrives as an ephemeral follow-up: the
|
||||
* interaction token stays valid, so this is a supported path rather than a
|
||||
* trick, and the cost is a "thinking…" that appears and vanishes.
|
||||
*
|
||||
* There is no reverse case. A command deferred ephemerally is one whose answers
|
||||
* are all about the caller's own account, and nothing it returns should become
|
||||
* public because a handler forgot a flag.
|
||||
*/
|
||||
async function reply(interaction, envelope, deferredEphemeral) {
|
||||
const payload = render(envelope)
|
||||
if (!envelope.ephemeral || deferredEphemeral) {
|
||||
await interaction.editReply(payload)
|
||||
return
|
||||
}
|
||||
await interaction.deleteReply()
|
||||
await interaction.followUp({ ...payload, ephemeral: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer, dispatch, edit.
|
||||
*
|
||||
* **The deferral comes first, always.** Discord gives three seconds to acknowledge
|
||||
* an interaction; the app is given four to answer. Deferring before the dispatch
|
||||
* is what keeps the website out of that critical path entirely — a wedged handler
|
||||
* costs its own reply and never an "application did not respond".
|
||||
*
|
||||
* A failure at any point after the defer is an edit, not a reply: the interaction
|
||||
* has already been acknowledged, and `reply()` on a deferred interaction throws.
|
||||
*/
|
||||
async function execute(interaction) {
|
||||
const definition = pulled.find((c) => c.name === interaction.commandName)
|
||||
if (!definition) return false
|
||||
|
||||
// **Ephemerality is fixed at the DEFERRAL, which happens before the answer
|
||||
// exists.** That is Discord's rule, not a choice here, and it is the whole
|
||||
// reason this needs care: the handler decides privacy per answer — a refusal
|
||||
// is private, a guild summary is not — and by the time it says so the reply is
|
||||
// already public or already not.
|
||||
//
|
||||
// So: defer for the common case (public, or private for a command that only
|
||||
// ever speaks about the caller's own account), and if the envelope disagrees,
|
||||
// reconcile below. Getting this wrong is not cosmetic — the live walk caught it
|
||||
// posting "guild information is not shown to your account" into the channel,
|
||||
// which announces a member's access level to everyone in it.
|
||||
const ephemeral = definition.access === 'linked'
|
||||
await interaction.deferReply({ ephemeral })
|
||||
|
||||
const res = await appInternal.dispatchCommand({
|
||||
command: definition.name,
|
||||
options: collectOptions(interaction, definition),
|
||||
platformUserId: interaction.user.id,
|
||||
guildId: interaction.guildId,
|
||||
})
|
||||
|
||||
// A transport failure and a handler failure are the same sentence to the
|
||||
// member and different lines in the log: one is the app being unreachable,
|
||||
// the other is a module's code.
|
||||
// A refusal is ALWAYS private, whatever the command's usual privacy: "you do
|
||||
// not have access to that" is about one member and belongs to one member.
|
||||
if (!res.ok) {
|
||||
log.warn('command dispatch failed', { command: definition.name, error: res.error })
|
||||
await reply(interaction, { text: refusal({ reason: 'error' }), ephemeral: true }, ephemeral)
|
||||
return true
|
||||
}
|
||||
if (!res.data || !res.data.ok) {
|
||||
await reply(interaction, { text: refusal(res.data || {}), ephemeral: true }, ephemeral)
|
||||
return true
|
||||
}
|
||||
|
||||
const envelope = res.data.response || {}
|
||||
await reply(interaction, envelope, ephemeral)
|
||||
|
||||
// The private aside beside a public answer (§9 answer 5). Skipped when the
|
||||
// reply was already private — the member would just be told the same thing
|
||||
// twice, in the same place.
|
||||
if (envelope.notice && !ephemeral && !envelope.ephemeral) {
|
||||
await interaction.followUp({ content: envelope.notice, ephemeral: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Test-only: the pulled set is process-global, so a test that pulls has to be
|
||||
// able to hand the process back.
|
||||
function _reset() {
|
||||
pulled = []
|
||||
version = null
|
||||
}
|
||||
|
||||
module.exports = { pull, definitions, has, execute, _reset }
|
||||
80
bot/src/discord/teamNotify.js
Normal file
80
bot/src/discord/teamNotify.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Team notifications posted into an operator-configured channel (TEAMS.md §7.2).
|
||||
//
|
||||
// **The channel comes from the app, not from guild_config.** `newsAnnounce` looks
|
||||
// its channel up here because there is exactly one #news; a Team's destination is
|
||||
// per-Team configuration living in `team_integration_config`, and a bot that
|
||||
// resolved it would need a second copy of that table and a second place for it to
|
||||
// drift. The app sends the id it already decided on.
|
||||
//
|
||||
// **Everything this file knows about a Team it was told.** No lookups, no
|
||||
// membership checks, no access decisions: whether this content may reach this
|
||||
// channel was settled on the site, where the acknowledgement that gates it lives.
|
||||
// The bot is the transport, exactly as it is for slash commands.
|
||||
const { EmbedBuilder } = require('discord.js')
|
||||
|
||||
const brand = require('../brand')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('team-notify')
|
||||
|
||||
// Discord's own limits. Truncating here rather than trusting the app is not
|
||||
// distrust — an embed that exceeds them is rejected wholesale, and a message
|
||||
// silently not appearing is the worst failure mode this path has.
|
||||
const TITLE_MAX = 256
|
||||
const DESCRIPTION_MAX = 4096
|
||||
|
||||
const clamp = (value, max) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return null
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
||||
}
|
||||
|
||||
// What each stream is called in a channel. The app composes the BODY; this is
|
||||
// only the label above it, and it is here because it is Discord presentation —
|
||||
// the same reason the embed colour is.
|
||||
const HEADINGS = {
|
||||
'team.member.joined': 'New member',
|
||||
'team.leadership.changed': 'Leadership change',
|
||||
'team.forum.post': 'New forum post',
|
||||
'team.announcement': 'Announcement',
|
||||
}
|
||||
|
||||
async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) {
|
||||
if (!channelId) throw new Error('No channel id supplied.')
|
||||
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null)
|
||||
if (!channel || !channel.isTextBased()) {
|
||||
throw new Error('Configured channel is missing, not text-based, or not visible to the bot.')
|
||||
}
|
||||
|
||||
const heading = HEADINGS[stream] || 'Team update'
|
||||
const name = clamp(teamName, 120) || 'A team'
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(brand.accentInt)
|
||||
// The Team is the AUTHOR line and the event is the title, not the other way
|
||||
// round: a channel carrying one Team's events would otherwise repeat its name
|
||||
// as every heading, and a channel carrying several needs the name to be the
|
||||
// thing the eye lands on first.
|
||||
.setAuthor(teamUrl ? { name, url: teamUrl } : { name })
|
||||
.setTitle(clamp(title, TITLE_MAX) || heading)
|
||||
|
||||
if (url) embed.setURL(url)
|
||||
|
||||
// Both a title and a body means a forum post: the heading has to go somewhere
|
||||
// or "New forum post" and "Announcement" become indistinguishable once the
|
||||
// thread title takes the title slot.
|
||||
//
|
||||
// **Clamped AFTER the heading is prepended, not before.** Clamping the body and
|
||||
// then adding a prefix produces a description one heading longer than the limit,
|
||||
// which discord.js rejects outright — so an over-long post would not arrive at
|
||||
// all rather than arriving truncated. The prefix is part of what has to fit.
|
||||
const composed = title && body ? `**${heading}**\n${String(body)}` : body
|
||||
const description = clamp(composed, DESCRIPTION_MAX)
|
||||
if (description) embed.setDescription(description)
|
||||
|
||||
await channel.send({ embeds: [embed] })
|
||||
log.info('team notification posted', { stream, channelId, team: name })
|
||||
}
|
||||
|
||||
module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX }
|
||||
315
bot/src/discord/teamVoice.js
Normal file
315
bot/src/discord/teamVoice.js
Normal file
@@ -0,0 +1,315 @@
|
||||
// Per-Team voice channels (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// **The site decides; this file compares and applies.** Every judgement — which
|
||||
// Teams qualify, who may enter, what the channel is called — was made on the site
|
||||
// and arrives in the request. What cannot be made there is the DIFF: which of
|
||||
// those people already hold the role, whether the channel still exists, whether
|
||||
// the category was deleted last week. That is live guild state, only this process
|
||||
// can see it, and shipping it to the site to be compared and shipped back would
|
||||
// be a copy of the guild in a database that cannot watch it change.
|
||||
//
|
||||
// So the contract is "make it look like this", not "do these calls".
|
||||
//
|
||||
// **Access is a per-Team ROLE.** §7.3 designed per-member permission overwrites
|
||||
// with a role only above ~90 members; the org lead settled on roles always
|
||||
// (2026-08-18). The channel therefore carries exactly three kinds of overwrite —
|
||||
// @everyone denied, the Team's role allowed, and each operator-designated staff
|
||||
// role allowed — and membership is the role's member list rather than a hundred
|
||||
// entries on the channel.
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js')
|
||||
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('team-voice')
|
||||
|
||||
// The category every Team channel is created under. Created on the first pass
|
||||
// that needs one; the site stores the id and sends it back next time.
|
||||
const CATEGORY_NAME = 'Teams'
|
||||
|
||||
// discord.js REST error codes for "the thing you are addressing is already gone".
|
||||
// A teardown that finds its target missing has SUCCEEDED — the desired end state
|
||||
// holds — and the same is true of a sync that finds a channel a human deleted,
|
||||
// which simply becomes a create.
|
||||
const UNKNOWN_CHANNEL = 10003
|
||||
const UNKNOWN_ROLE = 10011
|
||||
|
||||
const isMissing = (err) => err && (err.code === UNKNOWN_CHANNEL || err.code === UNKNOWN_ROLE)
|
||||
|
||||
// What a Team member may do in their channel, and what @everyone may not. Both
|
||||
// halves are needed: denying ViewChannel alone still leaves Connect resolvable
|
||||
// for anyone who has the id, and allowing ViewChannel alone shows a channel
|
||||
// nobody can enter.
|
||||
const ACCESS_BITS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect]
|
||||
|
||||
/**
|
||||
* Can this bot do §7.3's job in this guild?
|
||||
*
|
||||
* Asked before an operator may switch voice on, and again at the top of every
|
||||
* pass. The site has no way to know: the operator invites the bot by hand, there
|
||||
* is no invite URL with a permission integer anywhere in this project, and an
|
||||
* unticked box means every call fails with nothing to point at.
|
||||
*
|
||||
* `bot_role_position` is reported because it is the second, quieter failure:
|
||||
* ManageRoles lets the bot create a role, but it can only GRANT roles below its
|
||||
* own highest one. A bot sitting at the bottom of the role list creates roles it
|
||||
* then cannot hand to anybody — which looks exactly like a channel nobody can
|
||||
* enter, with no error anywhere.
|
||||
*/
|
||||
async function preflight(client, guildId) {
|
||||
const guild = await client.guilds.fetch(guildId)
|
||||
const me = guild.members.me || (await guild.members.fetchMe())
|
||||
return {
|
||||
connected: true,
|
||||
guild_id: guild.id,
|
||||
can_manage_channels: me.permissions.has(PermissionFlagsBits.ManageChannels),
|
||||
can_manage_roles: me.permissions.has(PermissionFlagsBits.ManageRoles),
|
||||
// The guild's whole role list, not just the ones this feature made. The
|
||||
// 250-role cap is guild-wide and shared with everything the operator created
|
||||
// themselves, so counting ours would promise headroom that is not there.
|
||||
role_count: guild.roles.cache.size,
|
||||
bot_role_position: me.roles.highest.position,
|
||||
}
|
||||
}
|
||||
|
||||
/** The `Teams` category, reusing the one we were given when it is still there. */
|
||||
async function ensureCategory(guild, categoryId) {
|
||||
if (categoryId) {
|
||||
const existing = await guild.channels.fetch(categoryId).catch(() => null)
|
||||
if (existing && existing.type === ChannelType.GuildCategory) return existing
|
||||
log.warn('the configured Teams category is gone; making another', { categoryId })
|
||||
}
|
||||
const created = await guild.channels.create({
|
||||
name: CATEGORY_NAME,
|
||||
type: ChannelType.GuildCategory,
|
||||
reason: 'Team voice channels',
|
||||
})
|
||||
log.info('created the Teams category', { categoryId: created.id })
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* The Team's own role.
|
||||
*
|
||||
* A rename is applied but never allowed to fail the pass: a Team's name is the
|
||||
* least important thing here and Discord rate-limits name edits hard, so losing
|
||||
* one is worth strictly less than losing the access change in the same request.
|
||||
*/
|
||||
async function ensureRole(guild, roleId, name) {
|
||||
let role = roleId ? await guild.roles.fetch(roleId).catch(() => null) : null
|
||||
let created = false
|
||||
if (!role) {
|
||||
role = await guild.roles.create({
|
||||
name,
|
||||
// Not mentionable and not hoisted: this role exists to open a door, and a
|
||||
// Team with two hundred members should not become a way to ping them all or
|
||||
// a second copy of the member list down the sidebar.
|
||||
mentionable: false,
|
||||
hoist: false,
|
||||
reason: 'Team voice access',
|
||||
})
|
||||
created = true
|
||||
log.info('created a team role', { roleId: role.id, name })
|
||||
} else if (role.name !== name) {
|
||||
await role.setName(name, 'Team renamed').catch((err) => {
|
||||
log.warn('could not rename the team role', { roleId: role.id, message: err.message })
|
||||
})
|
||||
}
|
||||
return { role, created }
|
||||
}
|
||||
|
||||
/** The overwrites a Team channel carries, in the order Discord takes them. */
|
||||
function overwritesFor(guild, role, staffRoleIds) {
|
||||
const overwrites = [
|
||||
{ id: guild.roles.everyone.id, deny: ACCESS_BITS },
|
||||
{ id: role.id, allow: ACCESS_BITS },
|
||||
]
|
||||
for (const staffId of staffRoleIds) {
|
||||
// A staff role the operator has since deleted would make Discord reject the
|
||||
// WHOLE set, taking the Team's own grant down with it. Filtered here rather
|
||||
// than validated on the site, which cannot see the guild's role list.
|
||||
if (!guild.roles.cache.has(staffId)) {
|
||||
log.warn('a configured staff role is not in this guild; skipping it', { roleId: staffId })
|
||||
continue
|
||||
}
|
||||
overwrites.push({ id: staffId, allow: ACCESS_BITS })
|
||||
}
|
||||
return overwrites
|
||||
}
|
||||
|
||||
async function ensureChannel(guild, channelId, { name, category, role, staffRoleIds }) {
|
||||
const overwrites = overwritesFor(guild, role, staffRoleIds)
|
||||
let channel = channelId ? await guild.channels.fetch(channelId).catch(() => null) : null
|
||||
|
||||
if (channel && channel.type !== ChannelType.GuildVoice) {
|
||||
// Somebody pointed us at, or converted this into, something that is not a
|
||||
// voice channel. Not ours to repurpose — make the right one and leave theirs.
|
||||
log.warn('the stored channel is not a voice channel; making a new one', { channelId })
|
||||
channel = null
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
const created = await guild.channels.create({
|
||||
name,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
permissionOverwrites: overwrites,
|
||||
reason: 'Team voice channel',
|
||||
})
|
||||
log.info('created a team voice channel', { channelId: created.id, name })
|
||||
return { channel: created, created: true }
|
||||
}
|
||||
|
||||
// Overwrites are re-set on every pass rather than diffed: the set is three or
|
||||
// four entries, `set` is one API call, and re-asserting it is what repairs a
|
||||
// channel somebody edited by hand.
|
||||
await channel.permissionOverwrites.set(overwrites, 'Team voice access')
|
||||
if (channel.parentId !== category.id) {
|
||||
await channel.setParent(category.id, { lockPermissions: false, reason: 'Team voice channel' })
|
||||
}
|
||||
if (channel.name !== name) {
|
||||
await channel.setName(name, 'Team renamed').catch((err) => {
|
||||
log.warn('could not rename the team voice channel', { channelId: channel.id, message: err.message })
|
||||
})
|
||||
}
|
||||
return { channel, created: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the role's member list to the site's list, up to `maxOps` changes.
|
||||
*
|
||||
* **Bounded, and the remainder is reported rather than dropped.** Each grant is
|
||||
* its own API call under its own rate limit, so an unbounded first pass on a
|
||||
* large guild is a request that outlives its own timeout — and a timeout is the
|
||||
* one outcome that leaves the site not knowing what was applied. The site asks
|
||||
* again until `pending` reaches zero.
|
||||
*
|
||||
* **A member the site names who is not in this guild is skipped silently.** They
|
||||
* linked their Discord account to the site and never joined the guild, which is
|
||||
* an ordinary state (§2.6 hop 3 without hop 4) and not something an operator
|
||||
* needs to see a hundred of.
|
||||
*/
|
||||
async function syncRoleMembers(guild, role, memberIds, maxOps) {
|
||||
// One fetch of the whole member list, so `role.members` and the "are they even
|
||||
// here" check both read from a cache that is actually populated. discord.js
|
||||
// keeps it current from gateway events afterwards; without the fetch, a bot
|
||||
// that has been up for five minutes knows only the members who spoke.
|
||||
await guild.members.fetch()
|
||||
|
||||
const desired = new Set(memberIds.map(String))
|
||||
const current = new Set(role.members.map((member) => member.id))
|
||||
|
||||
const toAdd = [...desired].filter((id) => !current.has(id) && guild.members.cache.has(id))
|
||||
const toRemove = [...current].filter((id) => !desired.has(id))
|
||||
|
||||
let ops = 0
|
||||
let added = 0
|
||||
let removed = 0
|
||||
|
||||
for (const id of toAdd) {
|
||||
if (ops >= maxOps) break
|
||||
const member = guild.members.cache.get(id)
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await member.roles.add(role, 'Team member')
|
||||
added += 1
|
||||
} catch (err) {
|
||||
// One member the bot cannot touch — almost always the role hierarchy, when
|
||||
// the member outranks the bot — must not cost the other forty-nine.
|
||||
log.warn('could not grant the team role', { userId: id, roleId: role.id, message: err.message })
|
||||
}
|
||||
ops += 1
|
||||
}
|
||||
|
||||
for (const id of toRemove) {
|
||||
if (ops >= maxOps) break
|
||||
const member = guild.members.cache.get(id)
|
||||
if (!member) continue
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await member.roles.remove(role, 'No longer a team member')
|
||||
removed += 1
|
||||
} catch (err) {
|
||||
log.warn('could not revoke the team role', { userId: id, roleId: role.id, message: err.message })
|
||||
}
|
||||
ops += 1
|
||||
}
|
||||
|
||||
return { added, removed, pending: Math.max(0, toAdd.length + toRemove.length - ops) }
|
||||
}
|
||||
|
||||
/** One Team, reconciled. */
|
||||
async function syncTeamVoice(client, guildId, {
|
||||
teamId, name, categoryId, channelId, roleId, staffRoleIds = [], memberIds = [], maxMemberOps = 50,
|
||||
}) {
|
||||
const guild = await client.guilds.fetch(guildId)
|
||||
const category = await ensureCategory(guild, categoryId)
|
||||
const { role, created: roleCreated } = await ensureRole(guild, roleId, name)
|
||||
const { channel, created: channelCreated } = await ensureChannel(guild, channelId, {
|
||||
name, category, role, staffRoleIds,
|
||||
})
|
||||
const members = await syncRoleMembers(guild, role, memberIds, maxMemberOps)
|
||||
|
||||
log.info('team voice reconciled', {
|
||||
teamId, name, channelId: channel.id, roleId: role.id, ...members,
|
||||
})
|
||||
|
||||
return {
|
||||
category_id: category.id,
|
||||
channel_id: channel.id,
|
||||
role_id: role.id,
|
||||
created: { channel: channelCreated, role: roleCreated },
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Team's channel and role.
|
||||
*
|
||||
* Both, in one call, because they are one lifecycle: deleting the channel and
|
||||
* leaving the role would leave every member wearing a badge for a place that no
|
||||
* longer exists. Either being already gone is success.
|
||||
*/
|
||||
async function removeTeamVoice(client, guildId, { channelId, roleId }) {
|
||||
const guild = await client.guilds.fetch(guildId)
|
||||
const result = { channel_deleted: false, role_deleted: false }
|
||||
|
||||
if (channelId) {
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null)
|
||||
if (channel) {
|
||||
try {
|
||||
await channel.delete('Team no longer qualifies for a voice channel')
|
||||
result.channel_deleted = true
|
||||
} catch (err) {
|
||||
if (!isMissing(err)) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (roleId) {
|
||||
const role = await guild.roles.fetch(roleId).catch(() => null)
|
||||
if (role) {
|
||||
try {
|
||||
await role.delete('Team no longer qualifies for a voice channel')
|
||||
result.role_deleted = true
|
||||
} catch (err) {
|
||||
if (!isMissing(err)) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info('team voice removed', { channelId, roleId, ...result })
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CATEGORY_NAME,
|
||||
ACCESS_BITS,
|
||||
preflight,
|
||||
ensureCategory,
|
||||
ensureRole,
|
||||
ensureChannel,
|
||||
overwritesFor,
|
||||
syncRoleMembers,
|
||||
syncTeamVoice,
|
||||
removeTeamVoice,
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
const discordManager = require('../discord/discordManager')
|
||||
const newsAnnounce = require('../discord/newsAnnounce')
|
||||
const teamNotify = require('../discord/teamNotify')
|
||||
const teamVoice = require('../discord/teamVoice')
|
||||
const modLog = require('../discord/modLog')
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
@@ -99,4 +101,142 @@ async function reverseModAction(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }
|
||||
// POST /internal/refresh-commands — the app's nudge that its registered
|
||||
// slash-command set has moved (TEAMS.md §7.1). No body: the bot re-pulls
|
||||
// `/internal/commands` and re-registers only if the set actually changed, so the
|
||||
// nudge stays a cheap thing the app can send on every module state change.
|
||||
//
|
||||
// Deliberately its OWN endpoint rather than riding on /internal/config, which
|
||||
// carries the decrypted bot token: saying "commands changed" should not require
|
||||
// the app to read a secret out of the database.
|
||||
//
|
||||
// Answers 200 even when disconnected — there is no application to register
|
||||
// against until the bot logs in, and `ready` pulls again anyway. A 5xx here
|
||||
// would make an ordinary module install look like a failure in the admin panel.
|
||||
async function refreshCommands(req, res) {
|
||||
try {
|
||||
const result = await discordManager.refreshCommands()
|
||||
return res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
log.error('refresh-commands failed', { message: err.message })
|
||||
return res.json({ ok: false, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /internal/team-notify — a Team notification the site has already decided
|
||||
// belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name,
|
||||
// team_url, title, body, url }.
|
||||
//
|
||||
// **The site chose the channel and the site checked the access.** Whether
|
||||
// members-only forum text may reach this channel is an acknowledgement recorded
|
||||
// against team_integration_config, and re-deciding it here would mean the bot
|
||||
// holding a copy of a policy it cannot see the inputs to.
|
||||
//
|
||||
// 503 when disconnected and 400 for a channel the bot cannot post to, matching
|
||||
// /internal/announce — the caller is one-shot and best-effort and only logs the
|
||||
// difference, but an operator debugging a silent channel needs the two to read
|
||||
// differently in the bot's log.
|
||||
async function teamNotifyHandler(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
|
||||
const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {}
|
||||
if (!channelId || !stream) {
|
||||
return res.status(400).json({ message: 'channel_id and stream are required' })
|
||||
}
|
||||
|
||||
try {
|
||||
await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url })
|
||||
return res.json({ posted: true })
|
||||
} catch (err) {
|
||||
log.warn('team-notify failed', { message: err.message, stream, channelId })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Voice channels (TEAMS.md §7.3, phase 9) ────────────────────────────────
|
||||
|
||||
// GET /internal/team-voice/preflight — can this bot do the job at all?
|
||||
//
|
||||
// Its own endpoint, and the app asks it BEFORE letting an operator switch voice
|
||||
// on. §7.3 assumed the bot could manage channels and roles; nothing in this
|
||||
// project has ever checked, because the operator invites the bot by hand and
|
||||
// there is no invite URL with a permission integer anywhere in the tree. Without
|
||||
// this the first symptom of an unticked box is every Team recording its own
|
||||
// identical error, which reads like forty problems instead of one.
|
||||
async function voicePreflight(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ connected: false, message: 'Bot is not connected' })
|
||||
try {
|
||||
return res.json(await teamVoice.preflight(connection.client, connection.guildId))
|
||||
} catch (err) {
|
||||
log.warn('voice preflight failed', { message: err.message })
|
||||
return res.status(400).json({ connected: true, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /internal/team-voice/sync — make one Team's channel, role and role
|
||||
// membership match what the site sent.
|
||||
//
|
||||
// The site sends DESIRED STATE and this works out the calls, which is the
|
||||
// opposite of the split every other endpoint here uses. The decisions are all
|
||||
// still the site's; what is here is the comparison against live guild state,
|
||||
// which only this process can see.
|
||||
async function voiceSync(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
|
||||
const {
|
||||
team_id: teamId, name, category_id: categoryId, channel_id: channelId, role_id: roleId,
|
||||
staff_role_ids: staffRoleIds, member_ids: memberIds, max_member_ops: maxMemberOps,
|
||||
} = req.body || {}
|
||||
|
||||
if (!name) return res.status(400).json({ message: 'name is required' })
|
||||
|
||||
try {
|
||||
const result = await teamVoice.syncTeamVoice(connection.client, connection.guildId, {
|
||||
teamId,
|
||||
name,
|
||||
categoryId: categoryId || null,
|
||||
channelId: channelId || null,
|
||||
roleId: roleId || null,
|
||||
staffRoleIds: Array.isArray(staffRoleIds) ? staffRoleIds.map(String) : [],
|
||||
memberIds: Array.isArray(memberIds) ? memberIds.map(String) : [],
|
||||
maxMemberOps: Number(maxMemberOps) > 0 ? Number(maxMemberOps) : 50,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
// 400 rather than 500, matching /internal/announce: from the app's side this
|
||||
// is "Discord refused", which is a condition it records against the Team and
|
||||
// retries next pass — not a bug in this process.
|
||||
log.warn('voice sync failed', { message: err.message, teamId, name })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /internal/team-voice/remove — the grace window expired, or an admin said so.
|
||||
async function voiceRemove(req, res) {
|
||||
const connection = discordManager.getConnection()
|
||||
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||
|
||||
const { channel_id: channelId, role_id: roleId } = req.body || {}
|
||||
try {
|
||||
const result = await teamVoice.removeTeamVoice(connection.client, connection.guildId, { channelId, roleId })
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.warn('voice remove failed', { message: err.message, channelId, roleId })
|
||||
return res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setConfig,
|
||||
getStatus: getStatusHandler,
|
||||
announce,
|
||||
reverseModAction,
|
||||
refreshCommands,
|
||||
teamNotify: teamNotifyHandler,
|
||||
voicePreflight,
|
||||
voiceSync,
|
||||
voiceRemove,
|
||||
}
|
||||
|
||||
@@ -11,5 +11,10 @@ router.post('/config', ctrl.setConfig)
|
||||
router.get('/status', ctrl.getStatus)
|
||||
router.post('/announce', ctrl.announce)
|
||||
router.post('/mod-reverse', ctrl.reverseModAction)
|
||||
router.post('/refresh-commands', ctrl.refreshCommands)
|
||||
router.post('/team-notify', ctrl.teamNotify)
|
||||
router.get('/team-voice/preflight', ctrl.voicePreflight)
|
||||
router.post('/team-voice/sync', ctrl.voiceSync)
|
||||
router.post('/team-voice/remove', ctrl.voiceRemove)
|
||||
|
||||
module.exports = router
|
||||
|
||||
76
bot/src/site/appInternalClient.js
Normal file
76
bot/src/site/appInternalClient.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Shared-secret client for the APP's internal listener (port 3001) — the
|
||||
// bot→app direction of the channel `botInternalClient.js` runs app→bot.
|
||||
//
|
||||
// Two callers, both slash-command plumbing (TEAMS.md §7.1): pull the registered
|
||||
// command definitions, and dispatch one that a member has just run. Distinct
|
||||
// from siteApiClient.js, which reads the site's PUBLIC API with no secret at all.
|
||||
//
|
||||
// **The base URL is derived from `SITE_INTERNAL_URL`'s origin, not configured
|
||||
// separately.** That variable already points at the app's internal listener —
|
||||
// `http://app:3001/internal/bot-config` — and adding a second variable naming the
|
||||
// same host would be one more thing an operator can get half-right. Deriving it
|
||||
// means every existing deployment gains these endpoints with no compose change.
|
||||
const createLogger = require('../utils/logger')
|
||||
|
||||
const log = createLogger('app-internal')
|
||||
|
||||
const KEY = process.env.BOT_INTERNAL_KEY || ''
|
||||
|
||||
// §7.1's budget, and the same 4s `botInternalClient` uses in the other
|
||||
// direction. The app bounds its own handlers UNDER this (3s), so a timeout here
|
||||
// normally means the app itself is unreachable rather than a module being slow.
|
||||
const TIMEOUT_MS = 4000
|
||||
|
||||
function baseUrl() {
|
||||
const configured = process.env.SITE_INTERNAL_URL
|
||||
if (!configured) return null
|
||||
try {
|
||||
return new URL(configured).origin
|
||||
} catch {
|
||||
log.error('SITE_INTERNAL_URL is not a URL — slash-command registration is off', { configured })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function call(path, { method = 'GET', body } = {}) {
|
||||
const base = baseUrl()
|
||||
if (!base || !KEY) return { ok: false, error: 'SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set' }
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', 'X-Internal-Key': KEY },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) return { ok: false, status: res.status, error: `app responded ${res.status}` }
|
||||
return { ok: true, status: res.status, data: await res.json() }
|
||||
} catch (err) {
|
||||
log.warn('app internal call failed', { path, message: err.message })
|
||||
return { ok: false, status: 0, error: err.message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/** The registered slash-command definitions, plus the version they belong to. */
|
||||
function fetchCommands() {
|
||||
return call('/internal/commands')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one command in the app and get the response envelope back.
|
||||
*
|
||||
* The bot has already deferred by the time this is called, so the only deadline
|
||||
* that matters is Discord's 15-minute follow-up window — TIMEOUT_MS is about not
|
||||
* holding an interaction open on a wedged app, not about the 3-second ack.
|
||||
*/
|
||||
function dispatchCommand({ command, options, platformUserId, guildId }) {
|
||||
return call('/internal/commands/dispatch', {
|
||||
method: 'POST',
|
||||
body: { command, options, platform: 'discord', platformUserId, guildId },
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { fetchCommands, dispatchCommand }
|
||||
77
bot/test/appInternalClient.test.js
Normal file
77
bot/test/appInternalClient.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// The bot→app internal client (TEAMS.md §7.1).
|
||||
//
|
||||
// One property carries this file: the base URL is DERIVED from
|
||||
// `SITE_INTERNAL_URL`, which already names the app's internal listener with a
|
||||
// path on the end. That derivation is the reason every existing deployment gains
|
||||
// slash commands with no compose change, and it is exactly the kind of string
|
||||
// handling that breaks silently — a wrong base means "the app is down" forever,
|
||||
// with nothing in the logs but a fetch error.
|
||||
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const env = { ...process.env }
|
||||
const realFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.SITE_INTERNAL_URL = 'http://app:3001/internal/bot-config'
|
||||
process.env.BOT_INTERNAL_KEY = 'shh'
|
||||
delete require.cache[require.resolve('../src/site/appInternalClient')]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...env }
|
||||
global.fetch = realFetch
|
||||
})
|
||||
|
||||
/** Load the client fresh and record the single fetch it makes. */
|
||||
function withFetch(response) {
|
||||
const seen = {}
|
||||
global.fetch = async (url, init) => {
|
||||
seen.url = url
|
||||
seen.init = init
|
||||
return response
|
||||
}
|
||||
// eslint-disable-next-line global-require
|
||||
return { client: require('../src/site/appInternalClient'), seen }
|
||||
}
|
||||
|
||||
const ok = (body) => ({ ok: true, status: 200, json: async () => body })
|
||||
|
||||
test('the commands URL is the internal listener’s origin, not its bot-config path', async () => {
|
||||
const { client, seen } = withFetch(ok({ version: 3, commands: [] }))
|
||||
const res = await client.fetchCommands()
|
||||
assert.equal(seen.url, 'http://app:3001/internal/commands')
|
||||
assert.equal(seen.init.headers['X-Internal-Key'], 'shh')
|
||||
assert.deepEqual(res.data, { version: 3, commands: [] })
|
||||
})
|
||||
|
||||
test('a dispatch names the platform, so the app never has to guess', async () => {
|
||||
const { client, seen } = withFetch(ok({ ok: true, response: {} }))
|
||||
await client.dispatchCommand({ command: 'guild', options: { name: 'KOC' }, platformUserId: '5', guildId: '9' })
|
||||
assert.equal(seen.url, 'http://app:3001/internal/commands/dispatch')
|
||||
assert.deepEqual(JSON.parse(seen.init.body), {
|
||||
command: 'guild', options: { name: 'KOC' }, platform: 'discord', platformUserId: '5', guildId: '9',
|
||||
})
|
||||
})
|
||||
|
||||
// A bot with no internal URL configured is an ordinary deployment state (the
|
||||
// warning already exists in bootstrap.js); it must not become an exception on
|
||||
// every `ready`.
|
||||
test('an unconfigured or unparseable SITE_INTERNAL_URL is a refusal, not a throw', async () => {
|
||||
delete process.env.SITE_INTERNAL_URL
|
||||
const { client } = withFetch(ok({}))
|
||||
assert.equal((await client.fetchCommands()).ok, false)
|
||||
|
||||
delete require.cache[require.resolve('../src/site/appInternalClient')]
|
||||
process.env.SITE_INTERNAL_URL = 'not a url'
|
||||
// eslint-disable-next-line global-require
|
||||
assert.equal((await require('../src/site/appInternalClient').fetchCommands()).ok, false)
|
||||
})
|
||||
|
||||
test('a non-2xx carries its status so the caller can tell "down" from "rejected"', async () => {
|
||||
const { client } = withFetch({ ok: false, status: 401, json: async () => ({}) })
|
||||
const res = await client.fetchCommands()
|
||||
assert.equal(res.ok, false)
|
||||
assert.equal(res.status, 401)
|
||||
})
|
||||
269
bot/test/dynamicCommands.test.js
Normal file
269
bot/test/dynamicCommands.test.js
Normal file
@@ -0,0 +1,269 @@
|
||||
// ── The bot's half of module slash commands (TEAMS.md §7.1) ────────────────
|
||||
//
|
||||
// The first tests in this package, and they exist for a specific reason: phases
|
||||
// 8 and 9 put more of the Discord integration in this process, and the failure
|
||||
// modes here are ones no unit test in `server/` can see — a whole-set PUT that
|
||||
// one bad entry poisons, a deferral that has to happen before anything slow, and
|
||||
// a reply that must be EDITED rather than sent once the interaction is deferred.
|
||||
//
|
||||
// Nothing here talks to Discord. `interaction` is a fake that records what was
|
||||
// called on it, which is the whole of what this file is asserting about.
|
||||
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const dynamic = require('../src/discord/dynamicCommands')
|
||||
const appInternal = require('../src/site/appInternalClient')
|
||||
const staticCommands = require('../src/discord/commands')
|
||||
|
||||
const originals = {
|
||||
fetchCommands: appInternal.fetchCommands,
|
||||
dispatchCommand: appInternal.dispatchCommand,
|
||||
get: staticCommands.get,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dynamic._reset()
|
||||
Object.assign(appInternal, originals)
|
||||
staticCommands.get = originals.get
|
||||
})
|
||||
|
||||
const definition = (over = {}) => ({
|
||||
name: 'guild',
|
||||
description: 'Show a guild',
|
||||
owner: 'uo',
|
||||
access: 'everyone',
|
||||
options: [{ name: 'name', type: 'string', description: 'Guild name', required: false }],
|
||||
...over,
|
||||
})
|
||||
|
||||
const answers = (commands, version = 1) => {
|
||||
appInternal.fetchCommands = async () => ({ ok: true, data: { version, commands } })
|
||||
}
|
||||
|
||||
function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' } = {}) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
commandName,
|
||||
guildId: '999',
|
||||
user: { id: userId },
|
||||
options: {
|
||||
get: (name) => (name in options ? { value: options[name] } : null),
|
||||
},
|
||||
deferReply: async (payload) => calls.push(['defer', payload]),
|
||||
editReply: async (payload) => calls.push(['edit', payload]),
|
||||
deleteReply: async () => calls.push(['delete']),
|
||||
followUp: async (payload) => calls.push(['followUp', payload]),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pulling ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a pull reports whether the set moved, so a nudge is cheap', async () => {
|
||||
answers([definition()], 7)
|
||||
assert.deepEqual(await dynamic.pull(), { ok: true, changed: true, count: 1 })
|
||||
// Same version, same size: nothing to re-register, and re-registering anyway
|
||||
// would mean a REST.put per module state change instead of per real change.
|
||||
assert.deepEqual(await dynamic.pull(), { ok: true, changed: false, count: 1 })
|
||||
answers([definition()], 8)
|
||||
assert.equal((await dynamic.pull()).changed, true)
|
||||
})
|
||||
|
||||
// Otherwise a restart blip would deregister every module command from Discord
|
||||
// and re-register it a minute later, with members watching it happen.
|
||||
test('a failed pull keeps the set already registered', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.fetchCommands = async () => ({ ok: false, error: 'ECONNREFUSED' })
|
||||
assert.deepEqual(await dynamic.pull(), { ok: false, changed: false, count: 1 })
|
||||
assert.equal(dynamic.definitions().length, 1)
|
||||
})
|
||||
|
||||
// The collision the app cannot see: it validates against what IT registered and
|
||||
// does not know the bot's own array exists. Two entries of one name in a single
|
||||
// PUT is rejected as a batch, taking the built-ins down with it.
|
||||
test('a module command that collides with a built-in is dropped, not registered', async () => {
|
||||
staticCommands.get = (name) => (name === 'ping' ? { data: { name: 'ping' } } : undefined)
|
||||
answers([definition({ name: 'ping' }), definition()])
|
||||
await dynamic.pull()
|
||||
assert.deepEqual(dynamic.definitions().map((d) => d.name), ['guild'])
|
||||
assert.equal(dynamic.has('ping'), false)
|
||||
})
|
||||
|
||||
test('definitions carry Discord’s numeric option types, not the contract’s names', async () => {
|
||||
answers([definition({
|
||||
options: [
|
||||
{ name: 'who', type: 'user', description: 'A member', required: true },
|
||||
{ name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] },
|
||||
],
|
||||
})])
|
||||
await dynamic.pull()
|
||||
const [data] = dynamic.definitions()
|
||||
assert.deepEqual(data.options.map((o) => o.type), [6, 4])
|
||||
assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }])
|
||||
assert.equal(data.default_member_permissions, undefined)
|
||||
})
|
||||
|
||||
// `linked` has no Discord equivalent — there is no "has a website account"
|
||||
// predicate — so only `staff` maps, and the app re-checks both regardless.
|
||||
test('only access: staff becomes a Discord permission default', async () => {
|
||||
answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
const [staff, linked] = dynamic.definitions()
|
||||
assert.equal(typeof staff.default_member_permissions, 'string')
|
||||
assert.equal(linked.default_member_permissions, undefined)
|
||||
})
|
||||
|
||||
// ── Executing ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('the deferral happens before the dispatch, always', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
let deferredFirst = false
|
||||
const interaction = fakeInteraction()
|
||||
appInternal.dispatchCommand = async () => {
|
||||
deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer'
|
||||
return { ok: true, data: { ok: true, response: { text: 'hi' } } }
|
||||
}
|
||||
await dynamic.execute(interaction)
|
||||
assert.ok(deferredFirst, 'the website is never in Discord’s 3-second ack path')
|
||||
assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }])
|
||||
})
|
||||
|
||||
test('the options the member supplied are passed by name, as plain values', async () => {
|
||||
answers([definition({
|
||||
options: [
|
||||
{ name: 'name', type: 'string', description: 'd' },
|
||||
{ name: 'who', type: 'user', description: 'd' },
|
||||
{ name: 'missing', type: 'string', description: 'd' },
|
||||
],
|
||||
})])
|
||||
await dynamic.pull()
|
||||
let sent = null
|
||||
appInternal.dispatchCommand = async (body) => {
|
||||
sent = body
|
||||
return { ok: true, data: { ok: true, response: {} } }
|
||||
}
|
||||
await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } }))
|
||||
assert.deepEqual(sent.options, { name: 'KOC', who: '42' })
|
||||
assert.equal(sent.platformUserId, '555')
|
||||
assert.equal(sent.guildId, '999')
|
||||
})
|
||||
|
||||
test('a title or fields render as an embed; a bare text does not', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true,
|
||||
data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
const [, payload] = interaction.calls.at(-1)
|
||||
assert.equal(payload.embeds[0].title, 'Knights')
|
||||
assert.equal(payload.embeds[0].description, 'Alliance: Accord')
|
||||
assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7')
|
||||
})
|
||||
|
||||
// §9 answer 5: the public projection, plus a private nudge to link. One reply
|
||||
// cannot be both, so the aside is a follow-up — which is the bot's decision to
|
||||
// make, not the handler's.
|
||||
test('a notice becomes an ephemeral follow-up beside a public answer', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true,
|
||||
data: { ok: true, response: { text: 'public', notice: 'Link your account' } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }])
|
||||
})
|
||||
|
||||
test('a notice is not repeated when the answer was already private', async () => {
|
||||
answers([definition({ access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true,
|
||||
data: { ok: true, response: { text: 'private', notice: 'Link your account' } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }])
|
||||
assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false)
|
||||
})
|
||||
|
||||
// Every failure path EDITS. Replying to a deferred interaction throws, so a
|
||||
// refusal that used reply() would turn a clean "no" into an unhandled error.
|
||||
// Ephemerality is fixed at the DEFERRAL, which happens before the handler has
|
||||
// said anything — so honouring a per-answer flag needs the deferred reply
|
||||
// withdrawn. The live walk caught the version that ignored it posting "guild
|
||||
// information is not shown to your account" into the channel, which announces a
|
||||
// member's access level to everyone in it.
|
||||
test('a handler asking for privacy gets it, even though the deferral was public', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
|
||||
assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true })
|
||||
})
|
||||
|
||||
test('an already-private deferral just edits — no second message', async () => {
|
||||
answers([definition({ access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
|
||||
})
|
||||
|
||||
// "You do not have access to that" is about one member and belongs to one
|
||||
// member, whatever the command's usual privacy.
|
||||
test('a refusal is always private', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } })
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
|
||||
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
|
||||
})
|
||||
|
||||
test('a refusal is phrased by the bot and edited into the deferred reply', async () => {
|
||||
answers([definition({ access: 'linked' })])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({
|
||||
ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false },
|
||||
})
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
|
||||
// Deferred ephemerally (access: 'linked'), so the refusal is one edit and no
|
||||
// withdrawal — replying twice to a deferred interaction is what throws.
|
||||
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
|
||||
})
|
||||
|
||||
test('an unreachable app is the same sentence to the member and a different line in the log', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' })
|
||||
const interaction = fakeInteraction()
|
||||
await dynamic.execute(interaction)
|
||||
assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/)
|
||||
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
|
||||
})
|
||||
|
||||
test('an interaction for a command the app no longer serves is left alone', async () => {
|
||||
answers([definition()])
|
||||
await dynamic.pull()
|
||||
const interaction = fakeInteraction({ commandName: 'gone' })
|
||||
assert.equal(await dynamic.execute(interaction), false)
|
||||
assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours')
|
||||
})
|
||||
138
bot/test/teamNotify.test.js
Normal file
138
bot/test/teamNotify.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ────────
|
||||
//
|
||||
// Nothing here talks to Discord. `channel` is a fake that records what was sent,
|
||||
// and the assertions are about the three things this side genuinely owns:
|
||||
//
|
||||
// 1. **the channel comes from the app and is never looked up.** `newsAnnounce`
|
||||
// reads guild_config because there is one #news; a Team's destination is
|
||||
// per-Team configuration, and a bot that resolved it would hold a second
|
||||
// copy of a table it cannot see the inputs to;
|
||||
// 2. **a channel the bot cannot post to fails loudly rather than silently.** A
|
||||
// caller that is one-shot and best-effort only logs the difference, but an
|
||||
// operator debugging a quiet channel needs the bot's log to distinguish
|
||||
// "not connected" from "that id is not a text channel";
|
||||
// 3. **Discord's own limits are enforced here.** An embed that exceeds them is
|
||||
// rejected WHOLESALE, so a long forum body must be truncated on this side
|
||||
// even though the app already excerpted it — the app's limit is a product
|
||||
// decision and this one is a protocol constraint.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const teamNotify = require('../src/discord/teamNotify')
|
||||
|
||||
// A fake channel that records what it was sent. `isTextBased` is the one method
|
||||
// the code branches on, so it is the one worth making configurable.
|
||||
function fakeChannel({ textBased = true } = {}) {
|
||||
const sends = []
|
||||
return {
|
||||
sends,
|
||||
isTextBased: () => textBased,
|
||||
send: async (payload) => { sends.push(payload); return { id: 'm1' } },
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(channel, { throws = false } = {}) {
|
||||
return {
|
||||
channels: {
|
||||
fetch: async (id) => {
|
||||
if (throws) throw new Error('Unknown Channel')
|
||||
return id === 'chan-1' ? channel : null
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const post = (client, over = {}) => teamNotify.postTeamNotification(client, {
|
||||
channelId: 'chan-1',
|
||||
stream: 'team.forum.post',
|
||||
teamName: 'Blackthorn’s Legion',
|
||||
teamUrl: 'https://site/guilds/blackthorns-legion',
|
||||
title: 'Siege tonight',
|
||||
body: 'Meet at the moongate.',
|
||||
url: 'https://site/guilds/blackthorns-legion?thread=41',
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── 1. The channel is the app's decision ───────────────────────────────────
|
||||
|
||||
test('the message goes to the channel the app named', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel))
|
||||
assert.equal(channel.sends.length, 1)
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'Siege tonight')
|
||||
assert.equal(embed.data.author.name, 'Blackthorn’s Legion')
|
||||
assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41')
|
||||
})
|
||||
|
||||
test('no channel id at all is refused before anything is fetched', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/)
|
||||
})
|
||||
|
||||
// ── 2. A channel the bot cannot use ────────────────────────────────────────
|
||||
|
||||
test('a channel the bot cannot see is a clear error, not a silent no-op', async () => {
|
||||
await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/)
|
||||
})
|
||||
|
||||
test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/)
|
||||
})
|
||||
|
||||
test('a voice channel is refused', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/)
|
||||
})
|
||||
|
||||
// ── 3. Discord's limits, and the heading ───────────────────────────────────
|
||||
|
||||
test('an over-long title is truncated rather than rejected by Discord as a whole', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { title: 'y'.repeat(400) })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title.length, teamNotify.TITLE_MAX)
|
||||
assert.ok(embed.data.title.endsWith('…'))
|
||||
})
|
||||
|
||||
test('an over-long body is truncated to the description limit', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { body: 'z'.repeat(9000) })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32)
|
||||
})
|
||||
|
||||
test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.announcement' })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.match(embed.data.description, /^\*\*Announcement\*\*/)
|
||||
assert.match(embed.data.description, /Meet at the moongate\./)
|
||||
})
|
||||
|
||||
test('a roster event has no title, so the heading becomes the title', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'New member')
|
||||
assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one')
|
||||
})
|
||||
|
||||
test('an unknown stream still posts, under a neutral heading', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.something.new', title: null })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'Team update')
|
||||
})
|
||||
|
||||
test('a missing team name does not produce an embed with an empty author line', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { teamName: '', teamUrl: null })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.author.name, 'A team')
|
||||
assert.equal(embed.data.author.url, undefined)
|
||||
})
|
||||
|
||||
test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => {
|
||||
assert.equal(teamNotify.clamp(' ', 100), null)
|
||||
assert.equal(teamNotify.clamp('ok', 100), 'ok')
|
||||
})
|
||||
364
bot/test/teamVoice.test.js
Normal file
364
bot/test/teamVoice.test.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// ── The bot's half of Team voice channels (TEAMS.md §7.3, phase 9) ────────
|
||||
//
|
||||
// Nothing here talks to Discord. `fakeGuild` records the calls, and the
|
||||
// assertions are about the four things this side genuinely owns — the ones the
|
||||
// site cannot decide because it cannot see the guild:
|
||||
//
|
||||
// 1. **The overwrite set.** @everyone denied, the Team's role allowed, each
|
||||
// configured staff role allowed — and a staff role the operator has since
|
||||
// deleted is FILTERED, because Discord rejects the whole set for one bad id
|
||||
// and that would take the Team's own grant down with it.
|
||||
// 2. **The membership diff is bounded and the remainder is reported.** Each
|
||||
// grant is its own API call; an unbounded first pass on a large guild
|
||||
// outlives its own timeout, which is the one failure that leaves the site
|
||||
// not knowing what was applied.
|
||||
// 3. **A member who linked Discord but never joined the guild is skipped
|
||||
// silently.** That is §2.6 hop 3 without hop 4 — an ordinary state, not an
|
||||
// error, and certainly not a hundred log lines.
|
||||
// 4. **A missing target is success.** A teardown that finds its channel already
|
||||
// deleted has reached the desired end state; a sync that finds one deleted
|
||||
// simply creates it again.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js')
|
||||
const teamVoice = require('../src/discord/teamVoice')
|
||||
|
||||
const EVERYONE = 'guild-everyone'
|
||||
|
||||
function fakeMember(id, { canGrant = true } = {}) {
|
||||
const roles = new Set()
|
||||
return {
|
||||
id,
|
||||
roles: {
|
||||
cache: roles,
|
||||
add: async (role) => {
|
||||
if (!canGrant) throw new Error('Missing Permissions')
|
||||
roles.add(role.id)
|
||||
},
|
||||
remove: async (role) => { roles.delete(role.id) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fakeGuild({
|
||||
members = [],
|
||||
roles = [],
|
||||
channels = [],
|
||||
botPermissions = [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles],
|
||||
} = {}) {
|
||||
const memberMap = new Map(members.map((m) => [m.id, m]))
|
||||
const roleMap = new Map(roles.map((r) => [r.id, r]))
|
||||
const channelMap = new Map(channels.map((c) => [c.id, c]))
|
||||
const created = { roles: [], channels: [] }
|
||||
let nextId = 1000
|
||||
|
||||
const guild = {
|
||||
id: 'guild-1',
|
||||
created,
|
||||
roles: {
|
||||
everyone: { id: EVERYONE },
|
||||
cache: roleMap,
|
||||
fetch: async (id) => roleMap.get(id) || null,
|
||||
create: async (opts) => {
|
||||
const role = {
|
||||
id: String(nextId++),
|
||||
name: opts.name,
|
||||
members: [],
|
||||
setName: async (name) => { role.name = name },
|
||||
delete: async () => { roleMap.delete(role.id) },
|
||||
}
|
||||
roleMap.set(role.id, role)
|
||||
created.roles.push(opts)
|
||||
return role
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
cache: channelMap,
|
||||
fetch: async (id) => channelMap.get(id) || null,
|
||||
create: async (opts) => {
|
||||
const channel = {
|
||||
id: String(nextId++),
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
parentId: opts.parent || null,
|
||||
overwrites: opts.permissionOverwrites || [],
|
||||
permissionOverwrites: {
|
||||
set: async (list) => { channel.overwrites = list },
|
||||
},
|
||||
setParent: async (parentId) => { channel.parentId = parentId },
|
||||
setName: async (name) => { channel.name = name },
|
||||
delete: async () => { channelMap.delete(channel.id) },
|
||||
}
|
||||
channelMap.set(channel.id, channel)
|
||||
created.channels.push(opts)
|
||||
return channel
|
||||
},
|
||||
},
|
||||
members: {
|
||||
me: { permissions: { has: (bit) => botPermissions.includes(bit) }, roles: { highest: { position: 7 } } },
|
||||
cache: memberMap,
|
||||
fetch: async () => memberMap,
|
||||
},
|
||||
}
|
||||
return guild
|
||||
}
|
||||
|
||||
const fakeClient = (guild) => ({ guilds: { fetch: async () => guild } })
|
||||
|
||||
const voiceChannel = (id, over = {}) => {
|
||||
const channel = {
|
||||
id,
|
||||
name: 'The Silver Hand',
|
||||
type: ChannelType.GuildVoice,
|
||||
parentId: '500',
|
||||
overwrites: [],
|
||||
permissionOverwrites: { set: async (list) => { channel.overwrites = list } },
|
||||
setParent: async (parentId) => { channel.parentId = parentId },
|
||||
setName: async (name) => { channel.name = name },
|
||||
delete: async () => {},
|
||||
...over,
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
const category = (id = '500') => ({ id, type: ChannelType.GuildCategory })
|
||||
|
||||
const role = (id, name = 'The Silver Hand', members = []) => {
|
||||
const r = {
|
||||
id,
|
||||
name,
|
||||
members,
|
||||
setName: async (next) => { r.name = next },
|
||||
delete: async () => {},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Preflight ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('preflight reports both permissions and the guild-wide role count', async () => {
|
||||
const guild = fakeGuild({ roles: [role('1'), role('2')] })
|
||||
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
|
||||
assert.equal(result.can_manage_channels, true)
|
||||
assert.equal(result.can_manage_roles, true)
|
||||
// The GUILD's roles, not ours. The 250 cap is shared with everything the
|
||||
// operator made themselves, so counting only ours would promise headroom that
|
||||
// is not there.
|
||||
assert.equal(result.role_count, 2)
|
||||
assert.equal(result.bot_role_position, 7)
|
||||
})
|
||||
|
||||
test('preflight reports a missing permission rather than throwing', async () => {
|
||||
const guild = fakeGuild({ botPermissions: [PermissionFlagsBits.ManageChannels] })
|
||||
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
|
||||
assert.equal(result.can_manage_channels, true)
|
||||
assert.equal(result.can_manage_roles, false)
|
||||
})
|
||||
|
||||
// ── Overwrites ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the overwrite set denies @everyone and allows the Team role', () => {
|
||||
const guild = fakeGuild()
|
||||
const list = teamVoice.overwritesFor(guild, role('900'), [])
|
||||
assert.equal(list.length, 2)
|
||||
assert.equal(list[0].id, EVERYONE)
|
||||
assert.deepEqual(list[0].deny, teamVoice.ACCESS_BITS)
|
||||
assert.equal(list[1].id, '900')
|
||||
assert.deepEqual(list[1].allow, teamVoice.ACCESS_BITS)
|
||||
})
|
||||
|
||||
test('a configured staff role that still exists gets an allow', () => {
|
||||
const staff = role('777', 'Moderators')
|
||||
const guild = fakeGuild({ roles: [staff] })
|
||||
const list = teamVoice.overwritesFor(guild, role('900'), ['777'])
|
||||
assert.equal(list.length, 3)
|
||||
assert.equal(list[2].id, '777')
|
||||
})
|
||||
|
||||
test('a staff role deleted in Discord is skipped, not sent — it would void the whole set', () => {
|
||||
const guild = fakeGuild({ roles: [] })
|
||||
const list = teamVoice.overwritesFor(guild, role('900'), ['deleted-1'])
|
||||
assert.equal(list.length, 2)
|
||||
assert.ok(!list.some((o) => o.id === 'deleted-1'))
|
||||
})
|
||||
|
||||
// ── Ensure ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a missing category is created; an existing one is reused', async () => {
|
||||
const guild = fakeGuild()
|
||||
const made = await teamVoice.ensureCategory(guild, null)
|
||||
assert.equal(guild.created.channels.length, 1)
|
||||
assert.equal(guild.created.channels[0].type, ChannelType.GuildCategory)
|
||||
|
||||
const again = await teamVoice.ensureCategory(guild, made.id)
|
||||
assert.equal(again.id, made.id)
|
||||
assert.equal(guild.created.channels.length, 1)
|
||||
})
|
||||
|
||||
test('a category id pointing at something that is not a category makes a new one', async () => {
|
||||
const guild = fakeGuild({ channels: [voiceChannel('700')] })
|
||||
await teamVoice.ensureCategory(guild, '700')
|
||||
assert.equal(guild.created.channels.length, 1)
|
||||
})
|
||||
|
||||
test('the Team role is created not mentionable and not hoisted', async () => {
|
||||
const guild = fakeGuild()
|
||||
const { role: made, created } = await teamVoice.ensureRole(guild, null, 'The Silver Hand')
|
||||
assert.equal(created, true)
|
||||
assert.equal(made.name, 'The Silver Hand')
|
||||
// A Team with two hundred members must not become a way to ping them all, or a
|
||||
// second copy of the member list down the sidebar.
|
||||
assert.equal(guild.created.roles[0].mentionable, false)
|
||||
assert.equal(guild.created.roles[0].hoist, false)
|
||||
})
|
||||
|
||||
test('a renamed Team renames its role rather than making a second', async () => {
|
||||
const existing = role('900', 'Old Name')
|
||||
const guild = fakeGuild({ roles: [existing] })
|
||||
const { role: made, created } = await teamVoice.ensureRole(guild, '900', 'New Name')
|
||||
assert.equal(created, false)
|
||||
assert.equal(made.name, 'New Name')
|
||||
assert.equal(guild.created.roles.length, 0)
|
||||
})
|
||||
|
||||
test('a rename Discord refuses does not fail the pass — access matters more than a label', async () => {
|
||||
const existing = role('900', 'Old Name')
|
||||
existing.setName = async () => { throw new Error('rate limited') }
|
||||
const guild = fakeGuild({ roles: [existing] })
|
||||
const { role: made } = await teamVoice.ensureRole(guild, '900', 'New Name')
|
||||
assert.equal(made.id, '900')
|
||||
})
|
||||
|
||||
test('a channel a human deleted is simply created again', async () => {
|
||||
const guild = fakeGuild()
|
||||
const { channel, created } = await teamVoice.ensureChannel(guild, 'gone-1', {
|
||||
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
|
||||
})
|
||||
assert.equal(created, true)
|
||||
assert.equal(channel.type, ChannelType.GuildVoice)
|
||||
assert.equal(channel.parentId, '500')
|
||||
})
|
||||
|
||||
test('an existing channel has its overwrites re-asserted every pass', async () => {
|
||||
const existing = voiceChannel('600')
|
||||
const guild = fakeGuild({ channels: [existing] })
|
||||
const { created } = await teamVoice.ensureChannel(guild, '600', {
|
||||
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
|
||||
})
|
||||
assert.equal(created, false)
|
||||
// Re-setting rather than diffing is what repairs a channel somebody edited by
|
||||
// hand.
|
||||
assert.equal(existing.overwrites.length, 2)
|
||||
})
|
||||
|
||||
test('a channel that is no longer a voice channel is left alone and a new one made', async () => {
|
||||
const text = voiceChannel('600', { type: ChannelType.GuildText })
|
||||
const guild = fakeGuild({ channels: [text] })
|
||||
const { channel, created } = await teamVoice.ensureChannel(guild, '600', {
|
||||
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
|
||||
})
|
||||
assert.equal(created, true)
|
||||
assert.notEqual(channel.id, '600')
|
||||
})
|
||||
|
||||
// ── Membership ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the role is granted to the members the site named', async () => {
|
||||
const alice = fakeMember('a')
|
||||
const bob = fakeMember('b')
|
||||
const guild = fakeGuild({ members: [alice, bob] })
|
||||
const teamRole = role('900', 'The Silver Hand', [])
|
||||
|
||||
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a', 'b'], 50)
|
||||
assert.equal(result.added, 2)
|
||||
assert.equal(result.removed, 0)
|
||||
assert.equal(result.pending, 0)
|
||||
})
|
||||
|
||||
test('a member who left the Team has the role taken away', async () => {
|
||||
const alice = fakeMember('a')
|
||||
const bob = fakeMember('b')
|
||||
const guild = fakeGuild({ members: [alice, bob] })
|
||||
const teamRole = role('900', 'The Silver Hand', [alice, bob])
|
||||
|
||||
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a'], 50)
|
||||
assert.equal(result.added, 0)
|
||||
assert.equal(result.removed, 1)
|
||||
})
|
||||
|
||||
test('a member who linked Discord but never joined the guild is skipped without an error', async () => {
|
||||
const guild = fakeGuild({ members: [] })
|
||||
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['not-in-guild'], 50)
|
||||
assert.equal(result.added, 0)
|
||||
assert.equal(result.pending, 0)
|
||||
})
|
||||
|
||||
test('the diff is bounded and the remainder is REPORTED, not dropped', async () => {
|
||||
const members = Array.from({ length: 10 }, (_, i) => fakeMember(`m${i}`))
|
||||
const guild = fakeGuild({ members })
|
||||
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), members.map((m) => m.id), 4)
|
||||
assert.equal(result.added, 4)
|
||||
assert.equal(result.pending, 6)
|
||||
})
|
||||
|
||||
test('one member the bot cannot touch does not cost the other forty-nine', async () => {
|
||||
const ok1 = fakeMember('a')
|
||||
const nope = fakeMember('b', { canGrant: false })
|
||||
const ok2 = fakeMember('c')
|
||||
const guild = fakeGuild({ members: [ok1, nope, ok2] })
|
||||
|
||||
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['a', 'b', 'c'], 50)
|
||||
assert.equal(result.added, 2)
|
||||
})
|
||||
|
||||
// ── Teardown ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a teardown deletes the channel and the role together', async () => {
|
||||
const channel = voiceChannel('600')
|
||||
const teamRole = role('900')
|
||||
let deletedChannel = false
|
||||
let deletedRole = false
|
||||
channel.delete = async () => { deletedChannel = true }
|
||||
teamRole.delete = async () => { deletedRole = true }
|
||||
const guild = fakeGuild({ channels: [channel], roles: [teamRole] })
|
||||
|
||||
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: '600', roleId: '900' })
|
||||
assert.equal(deletedChannel, true)
|
||||
assert.equal(deletedRole, true)
|
||||
assert.equal(result.channel_deleted, true)
|
||||
assert.equal(result.role_deleted, true)
|
||||
})
|
||||
|
||||
test('a teardown whose target is already gone is success, not a failure to retry forever', async () => {
|
||||
const guild = fakeGuild({ channels: [], roles: [] })
|
||||
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: 'gone', roleId: 'gone' })
|
||||
assert.equal(result.channel_deleted, false)
|
||||
assert.equal(result.role_deleted, false)
|
||||
})
|
||||
|
||||
// ── The whole thing ────────────────────────────────────────────────────────
|
||||
|
||||
test('a first sync creates the category, the role and the channel, and grants the members', async () => {
|
||||
const alice = fakeMember('a')
|
||||
const guild = fakeGuild({ members: [alice] })
|
||||
|
||||
const result = await teamVoice.syncTeamVoice(fakeClient(guild), 'guild-1', {
|
||||
teamId: 1,
|
||||
name: 'The Silver Hand',
|
||||
categoryId: null,
|
||||
channelId: null,
|
||||
roleId: null,
|
||||
staffRoleIds: [],
|
||||
memberIds: ['a'],
|
||||
maxMemberOps: 50,
|
||||
})
|
||||
|
||||
assert.equal(result.created.channel, true)
|
||||
assert.equal(result.created.role, true)
|
||||
assert.ok(result.category_id)
|
||||
assert.ok(result.channel_id)
|
||||
assert.ok(result.role_id)
|
||||
assert.equal(result.members.added, 1)
|
||||
})
|
||||
@@ -42,10 +42,12 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
import Appeals from './routes/admin/views/Appeals.jsx'
|
||||
import ContentReports from './routes/admin/views/ContentReports.jsx'
|
||||
|
||||
// Player portal
|
||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||
@@ -55,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
|
||||
export default function App() {
|
||||
@@ -162,6 +166,7 @@ export default function App() {
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
<Route path="reports" element={<ContentReports />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
@@ -174,6 +179,10 @@ export default function App() {
|
||||
the volume in the first place. Declared here with the rest of
|
||||
core's routes, above the module-supplied ones below. */}
|
||||
<Route path="modules" element={<ModulesAdmin />} />
|
||||
{/* Staff-wide, like the moderation queues: the gate on the three
|
||||
actions that publish a game-written name is applied per request
|
||||
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
||||
<Route path="teams" element={<TeamsAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
@@ -197,6 +206,10 @@ export default function App() {
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
{/* PUBLIC, and grouped with the other tokened landings above rather
|
||||
than with the portal below: the person following an unsubscribe
|
||||
link is reading their mail, not signed in (TEAMS.md §6.4). */}
|
||||
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
@@ -211,6 +224,7 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
<Route path="/account/notifications" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
|
||||
@@ -133,6 +133,80 @@ export const api = {
|
||||
return req(`/public/wiki${withQs(s)}`)
|
||||
},
|
||||
wikiCategories: () => req('/public/wiki/categories'),
|
||||
|
||||
// ----- Teams (TEAMS.md §2.11, §4.3) -----
|
||||
//
|
||||
// Only the two calls CORE's own client makes. Core renders no Team pages — the
|
||||
// vocabulary belongs to whichever module owns the surface — so the index, the
|
||||
// roster and the player list are not here; a module that renders those calls
|
||||
// the same public API from its own client.
|
||||
//
|
||||
// The lookup exists because a module names a Team in its own terms and core
|
||||
// keys the feed by slug. Resolving that is core's job precisely so a module
|
||||
// never has to hold core's identifiers.
|
||||
teamByExternalId: (moduleId, externalId) =>
|
||||
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
|
||||
teamActivity: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.limit != null) qs.set('limit', String(opts.limit))
|
||||
if (opts.offset != null) qs.set('offset', String(opts.offset))
|
||||
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
|
||||
},
|
||||
// The Team FORUM, under /player because a participant may be a plain player and
|
||||
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
|
||||
// core's: only core resolves whether this viewer is inside the Team, and the
|
||||
// member/guest split is a security boundary. The module renders the PLACE.
|
||||
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
|
||||
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
|
||||
teamForumPost: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
||||
teamForumModerate: (slug, id, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
|
||||
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
|
||||
// routes from their thread-level cousins rather than the same route with a
|
||||
// target kind, because they answer to different rules: a reply is refused by a
|
||||
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
|
||||
teamForumReply: (slug, threadId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
|
||||
teamForumEditPost: (slug, postId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
|
||||
teamForumModeratePost: (slug, postId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
|
||||
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
|
||||
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
|
||||
// There is no leader-facing counterpart to this call and there should not be.
|
||||
teamForumReport: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
|
||||
teamForumUpload: (slug, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
|
||||
teamGrantAdd: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
|
||||
teamGrantRevoke: (slug, userId) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- notifications (TEAMS.md Part 6) -----
|
||||
//
|
||||
// Under /auth/me rather than /player: these are role-agnostic self-service, the
|
||||
// same rule that put the forum under /player rather than behind a staff gate.
|
||||
// The streams catalog and the per-stream subscriptions were built for the app
|
||||
// and had no web consumer at all until phase 6 gave them one.
|
||||
notificationStreams: () => req('/auth/me/notifications/streams'),
|
||||
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
|
||||
// `streams` is always sent, empty array included — the endpoint requires the
|
||||
// field, so clearing the last subscription must not become an absent key.
|
||||
setNotificationSubscriptions: (streams) =>
|
||||
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
||||
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
||||
setTeamNotificationPrefs: (teams) =>
|
||||
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
||||
// Unauthenticated, and the one write in the public tier: the caller is reading
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -247,8 +321,61 @@ export const api = {
|
||||
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
|
||||
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||
// The caller does not choose — the server decides from the live role — so
|
||||
// there is deliberately no "asRequest" argument to get wrong.
|
||||
listTeams: () => req('/admin/teams'),
|
||||
getTeam: (id) => req(`/admin/teams/${id}`),
|
||||
resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
|
||||
archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
|
||||
teamGrants: (id) => req(`/admin/teams/${id}/grants`),
|
||||
hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
|
||||
unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
|
||||
setTeamDisplayName: (id, displayName, reason) =>
|
||||
req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
|
||||
setTeamLeaderOverride: (id, body) =>
|
||||
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
|
||||
clearTeamLeaderOverride: (id, memberKey) =>
|
||||
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
|
||||
teamForumSettings: () => req('/admin/teams/forum/settings'),
|
||||
// The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a
|
||||
// moderator's admin panel never renders the panel that calls these.
|
||||
teamIntegrations: () => req('/admin/teams/integrations'),
|
||||
saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }),
|
||||
deleteTeamIntegration: (teamId) =>
|
||||
req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }),
|
||||
// Voice channels (TEAMS.md §7.3). Admin-only server-side, like the bridge.
|
||||
teamVoice: () => req('/admin/teams/voice'),
|
||||
saveTeamVoice: (body) => req('/admin/teams/voice', { method: 'PUT', body }),
|
||||
teamVoicePass: () => req('/admin/teams/voice/sync', { method: 'POST' }),
|
||||
removeTeamVoice: (teamId) => req(`/admin/teams/voice/${teamId}`, { method: 'DELETE' }),
|
||||
teamForumUploads: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.deleted) qs.set('deleted', '1')
|
||||
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
|
||||
},
|
||||
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
|
||||
teamReviewQueue: () => req('/admin/teams/review'),
|
||||
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
|
||||
decideTeamRequest: (id, status, note) =>
|
||||
req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
|
||||
// under Teams because a staffer working a queue should have one place to
|
||||
// work, and a report about a forum post is the same job as a report about
|
||||
// anything else — which is also why `targetType` is open-ended.
|
||||
contentReports: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.status) qs.set('status', opts.status)
|
||||
if (opts.teamId) qs.set('teamId', String(opts.teamId))
|
||||
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
|
||||
},
|
||||
handleContentReport: (id, body) =>
|
||||
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
|
||||
100
client/src/lib/teamActivity.js
Normal file
100
client/src/lib/teamActivity.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// What core's Team activity feed SAYS, separated from how it renders
|
||||
// (docs/website/TEAMS.md §4.3).
|
||||
//
|
||||
// Core renders this feed into a slot a MODULE declares on its own page, because
|
||||
// Teams is a contract primitive and not a surface: core owns the feed, its
|
||||
// visibility rules and its wording; the module owns the page and the vocabulary
|
||||
// around it. So this file is deliberately narrow — the roster and index
|
||||
// presentation that once lived here went with the core Team pages, to whichever
|
||||
// module renders them.
|
||||
//
|
||||
// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
|
||||
// reason it was there: a feed that is filtered, or a projection that is stale,
|
||||
// has to say so in words, and getting that wording right is logic rather than
|
||||
// markup.
|
||||
|
||||
const MINUTE = 60_000
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
|
||||
export function relativeTime(when, now = Date.now()) {
|
||||
if (!when) return null
|
||||
const ms = now - new Date(when).getTime()
|
||||
if (!Number.isFinite(ms)) return null
|
||||
if (ms < MINUTE) return 'just now'
|
||||
if (ms < HOUR) {
|
||||
const n = Math.floor(ms / MINUTE)
|
||||
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
|
||||
}
|
||||
if (ms < DAY) {
|
||||
const n = Math.floor(ms / HOUR)
|
||||
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
|
||||
}
|
||||
const n = Math.floor(ms / DAY)
|
||||
return `${n} ${n === 1 ? 'day' : 'days'} ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* How a public surface describes the projection's freshness (§2.4).
|
||||
*
|
||||
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
|
||||
* debugging a sync. A visitor needs one sentence about whether what they are
|
||||
* looking at is current, and specifically must never be shown an unconfirmed
|
||||
* empty projection as though it were a confirmed empty shard.
|
||||
*/
|
||||
export function freshnessNote(sync = {}, now = Date.now()) {
|
||||
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
|
||||
// deployment with no game module is not a broken one.
|
||||
if (!sync.configured) return null
|
||||
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
|
||||
const ago = relativeTime(sync.lastSyncAt, now)
|
||||
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
|
||||
return { tone: 'idle', text: `Last confirmed ${ago}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group feed items into days, newest first, preserving order within a day (§4.3).
|
||||
*
|
||||
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
|
||||
* property of where the reader is sitting, and a shard's evening raid landing at
|
||||
* 00:30 UTC belongs on the day the players experienced it.
|
||||
*/
|
||||
export function groupByDay(items = [], locale = undefined) {
|
||||
const days = []
|
||||
const byKey = new Map()
|
||||
for (const item of items) {
|
||||
const date = new Date(item.occurredAt)
|
||||
if (Number.isNaN(date.getTime())) continue
|
||||
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
|
||||
if (!byKey.has(key)) {
|
||||
const day = {
|
||||
key,
|
||||
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
items: [],
|
||||
}
|
||||
byKey.set(key, day)
|
||||
days.push(day)
|
||||
}
|
||||
byKey.get(key).items.push(item)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/**
|
||||
* What to say under a feed that has been filtered.
|
||||
*
|
||||
* Only when there is something to say: a caller who saw everything is told
|
||||
* nothing, and an anonymous caller is invited to sign in rather than simply
|
||||
* informed that entries exist which they cannot have.
|
||||
*
|
||||
* The wording avoids core's own noun. The reader is looking at a page the module
|
||||
* titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
|
||||
* onto a surface that deliberately does not use it.
|
||||
*/
|
||||
export function activityScopeNote(feed = {}, signedIn = false) {
|
||||
if (feed.scope !== 'public') return null
|
||||
return signedIn
|
||||
? 'Some entries are visible to members only.'
|
||||
: 'Sign in as a member to see the members-only entries.'
|
||||
}
|
||||
140
client/src/lib/teamAdmin.js
Normal file
140
client/src/lib/teamAdmin.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
|
||||
// §2.4, §2.8, §2.9).
|
||||
//
|
||||
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
|
||||
// splitting here specifically: this screen's job is to tell an operator the
|
||||
// difference between "the shard has no Teams" and "core has not been able to ask
|
||||
// for two hours", and those two produce almost the same page. Getting that
|
||||
// wording right is logic, not markup.
|
||||
|
||||
/** Tones the screen uses. Names, not colours — the view maps them. */
|
||||
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
|
||||
|
||||
/**
|
||||
* How to describe the projection's freshness.
|
||||
*
|
||||
* The four states are genuinely different and an operator needs to tell them
|
||||
* apart:
|
||||
*
|
||||
* - no provider registered — nothing to sync, and not a fault;
|
||||
* - never synced — core has an empty projection it has never confirmed, which
|
||||
* must NOT read as "there are no Teams";
|
||||
* - stale — the projection is real but old, and the reason is usually in
|
||||
* `lastError`;
|
||||
* - current.
|
||||
*/
|
||||
export function freshnessOf(sync = {}) {
|
||||
if (!sync.configured) {
|
||||
return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
|
||||
}
|
||||
if (!sync.lastSyncAt) {
|
||||
return {
|
||||
tone: TONE.bad,
|
||||
label: 'Never synced',
|
||||
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
|
||||
}
|
||||
}
|
||||
if (sync.stale) {
|
||||
return {
|
||||
tone: TONE.warn,
|
||||
label: 'Stale',
|
||||
detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
|
||||
}
|
||||
}
|
||||
return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, human age. Deliberately coarse: this exists so a sentence reads
|
||||
* "confirmed 14 minutes ago", and second-level precision would be false comfort
|
||||
* about a projection whose interval is fifteen minutes.
|
||||
*/
|
||||
export function ago(value) {
|
||||
if (!value) return 'never'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
|
||||
if (seconds < 90) return 'just now'
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes} minutes ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||
return `${Math.round(hours / 24)} days ago`
|
||||
}
|
||||
|
||||
/** The status pill for one Team row. */
|
||||
export function statusOf(team = {}) {
|
||||
if (team.status === 'archived') {
|
||||
return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
|
||||
}
|
||||
if (team.hidden && team.hiddenReason === 'reserved_name') {
|
||||
return { tone: TONE.bad, label: 'Hidden — reserved name' }
|
||||
}
|
||||
if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
|
||||
return { tone: TONE.ok, label: 'Public' }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a staff member is told will happen when they press the button.
|
||||
*
|
||||
* The gate is decided server-side from the caller's live role, so this only
|
||||
* describes it. Saying "Request" to a moderator and "Apply" to an admin is what
|
||||
* stops the pending result being a surprise.
|
||||
*/
|
||||
export function gateLabelFor(role, verb) {
|
||||
return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
|
||||
}
|
||||
|
||||
/** The three gated actions, for the note under the buttons. */
|
||||
export const GATED_NOTE =
|
||||
'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change '
|
||||
+ 'is filed for approval. Hiding is not gated — suppression is always safe.'
|
||||
|
||||
/** A one-line description of a queued request, for the approval queue. */
|
||||
export function describeRequest(request = {}) {
|
||||
const payload = parsePayload(request.payload)
|
||||
const who = request.requested_username || 'a deleted user'
|
||||
switch (request.action) {
|
||||
case 'unhide':
|
||||
return `${who} asks to publish “${request.team_name}”`
|
||||
case 'display_name_override':
|
||||
return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”`
|
||||
case 'clear_display_name_override':
|
||||
return `${who} asks to clear the display name on “${request.team_name}”`
|
||||
default:
|
||||
return `${who} asks for “${request.action}” on “${request.team_name}”`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload may arrive parsed or as a JSON string depending on the driver, so
|
||||
* this normalises rather than assuming either. The server has the same note.
|
||||
*/
|
||||
export function parsePayload(payload) {
|
||||
if (payload == null) return {}
|
||||
if (typeof payload === 'object') return payload
|
||||
try {
|
||||
return JSON.parse(payload)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a member's leadership should read.
|
||||
*
|
||||
* An override is shown AS an override rather than folded into the answer: staff
|
||||
* looking at a roster need to see that a decision was made, not a fact that looks
|
||||
* like the game's.
|
||||
*/
|
||||
export function leadershipOf(member = {}) {
|
||||
if (!member.leaderOverride) {
|
||||
return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
|
||||
}
|
||||
const granted = member.leaderOverride.effect === 'grant'
|
||||
return {
|
||||
isLeader: granted,
|
||||
overridden: true,
|
||||
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
|
||||
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
|
||||
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
|
||||
}
|
||||
}
|
||||
85
client/src/lib/teamForum.js
Normal file
85
client/src/lib/teamForum.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5).
|
||||
//
|
||||
// This file is small on purpose. **Almost nothing about the forum is the
|
||||
// client's to decide**: who may post, who may moderate, whether an image
|
||||
// renders, and whether a post may be edited are all answered by the server and
|
||||
// read from the payload. What is left here is the handful of pure functions that
|
||||
// turn those answers into what a reader sees, and they are extracted so they can
|
||||
// be tested without a browser.
|
||||
//
|
||||
// The one that deserves a second look is `editOfferOpen`. It can only ever take
|
||||
// an offer AWAY — the server grants the edit and re-derives the window from
|
||||
// `created_at` when the write arrives. A client that granted one would be
|
||||
// deciding a time-bounded permission against the clock of the party it bounds.
|
||||
|
||||
export const REPORT_REASONS = [
|
||||
['abuse', 'Abusive or harassing'],
|
||||
['spam', 'Spam'],
|
||||
['sexual', 'Sexual content'],
|
||||
['illegal', 'Illegal content'],
|
||||
['impersonation', 'Impersonation'],
|
||||
['other', 'Something else'],
|
||||
]
|
||||
|
||||
/**
|
||||
* Should the Edit control still be offered for this post?
|
||||
*
|
||||
* Three states, and the middle one is the reason this exists:
|
||||
* • the server said no → no offer, and nothing here can create one
|
||||
* • the server said yes, no deadline (staff) → offer
|
||||
* • the server said yes with a deadline that has since passed while the page
|
||||
* sat open → withdraw the offer, rather than leave a button that fails
|
||||
*/
|
||||
export function editOfferOpen(post, now = Date.now()) {
|
||||
if (!post || !post.canEdit) return false
|
||||
if (!post.editableUntil) return true
|
||||
const until = new Date(post.editableUntil).getTime()
|
||||
return Number.isFinite(until) && until > now
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a rendered body back into something an author can edit.
|
||||
*
|
||||
* The server stores sanitised HTML and generates images at READ time from the
|
||||
* URLs an author wrote (§5.5.3), so what comes back is not what was typed. The
|
||||
* `<img>` has to go — it is core's output, not the author's input, and leaving it
|
||||
* in would let an author "edit" markup they never wrote and cannot control.
|
||||
* The URL survives as the link text beside it, which is what re-renders.
|
||||
*/
|
||||
export function stripToText(html) {
|
||||
return String(html || '')
|
||||
.replace(/<img[^>]*>/gi, '')
|
||||
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
// Entities last: unescaping before tag-stripping would let an escaped
|
||||
// "<script>" become a real tag the next pass then removes, which is a
|
||||
// different string from the one the author wrote.
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
// `&` last of all, or "&lt;" would decode two steps into "<".
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary under a thread's title in the list.
|
||||
*
|
||||
* `postCount` counts every post including the opening one, so a discussion's
|
||||
* REPLY count is one less — and an announcement has no replies to count at all,
|
||||
* which is why the count is omitted rather than shown as zero.
|
||||
*/
|
||||
export function threadSummary(thread) {
|
||||
const parts = []
|
||||
if (thread.type === 'announcement') parts.push('Announcement')
|
||||
parts.push(thread.author)
|
||||
if (thread.type === 'discussion' && thread.postCount > 1) {
|
||||
const replies = thread.postCount - 1
|
||||
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
|
||||
}
|
||||
if (thread.status === 'hidden') parts.push('hidden')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
103
client/src/lib/teamIntegrations.js
Normal file
103
client/src/lib/teamIntegrations.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8).
|
||||
//
|
||||
// The view is a form; these are the rules it applies, extracted for the same
|
||||
// reason `teamAdmin.js` is: the interesting parts are decisions — when the
|
||||
// acknowledgement dialog opens, and when a standing acknowledgement stops being
|
||||
// valid — and a decision embedded in JSX is one nothing can assert on.
|
||||
//
|
||||
// **The rules here MIRROR the server's and do not replace them.** The server
|
||||
// refuses to enable a members-only bridge without the acknowledgement (422)
|
||||
// whether or not this file ever ran. What is here is so the screen agrees with
|
||||
// that answer before making the round trip, rather than showing an operator a
|
||||
// save that fails for a reason the form did not mention.
|
||||
|
||||
// Wording an operator reads, per event id the server offers. Presentation, so it
|
||||
// lives on this side; the one bit that is policy — which events are members-only —
|
||||
// comes from the server with each event.
|
||||
export const EVENT_LABELS = {
|
||||
'team.member.joined': 'New members joined',
|
||||
'team.leadership.changed': 'Leadership changed',
|
||||
'team.forum.post': 'New forum post',
|
||||
'team.announcement': 'Announcement posted',
|
||||
}
|
||||
|
||||
export const eventLabel = (id) => EVENT_LABELS[id] || id
|
||||
|
||||
/** A row's identity in a list. `null` and `undefined` are both the default row. */
|
||||
export const rowKey = (row) =>
|
||||
(row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id))
|
||||
|
||||
export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined
|
||||
|
||||
export const blankDraft = (teamId = null) => ({
|
||||
teamId,
|
||||
events: [],
|
||||
channelRef: '',
|
||||
enabled: false,
|
||||
membersAck: false,
|
||||
})
|
||||
|
||||
export const draftFrom = (row) => ({
|
||||
teamId: row.team_id ?? null,
|
||||
events: row.events || [],
|
||||
channelRef: row.channel_ref || '',
|
||||
enabled: !!row.enabled,
|
||||
membersAck: !!row.members_ack,
|
||||
})
|
||||
|
||||
export function appliesToLabel(row, fallback = 'All Teams') {
|
||||
if (isDefaultRow(row)) return fallback
|
||||
return row.display_name_override || row.team_name || `Team #${row.team_id}`
|
||||
}
|
||||
|
||||
/** Toggle one event in a draft, preserving order of first selection. */
|
||||
export const toggleEvent = (draft, id) => ({
|
||||
...draft,
|
||||
events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id],
|
||||
})
|
||||
|
||||
/**
|
||||
* Repointing the row drops a standing acknowledgement, in the SAME place the
|
||||
* server does.
|
||||
*
|
||||
* Leaving the tick showing while the server has already decided to clear it is
|
||||
* the one way this screen could actively mislead: an operator repoints a row at a
|
||||
* public channel, sees "members-only destination confirmed" still ticked, and
|
||||
* believes the confirmation they gave for a private channel covers the new one.
|
||||
*/
|
||||
export function setChannel(draft, channelRef) {
|
||||
if (channelRef === draft.channelRef) return draft
|
||||
return { ...draft, channelRef, membersAck: false }
|
||||
}
|
||||
|
||||
/** Does this draft carry anything that would publish members-only text? */
|
||||
export const carriesMembersOnly = (draft, membersOnlyIds) =>
|
||||
draft.events.some((id) => membersOnlyIds.includes(id))
|
||||
|
||||
/**
|
||||
* Should saving stop and ask first?
|
||||
*
|
||||
* Only when ENABLING. A draft that carries forum events but is switched off is a
|
||||
* configuration being written, not a channel being published to — asking then
|
||||
* would make an operator confirm something they have not decided to do yet, which
|
||||
* is how a confirmation dialog becomes a thing people click through.
|
||||
*/
|
||||
export const needsAcknowledgement = (draft, membersOnlyIds) =>
|
||||
!!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck
|
||||
|
||||
/** The ids of every event the server flagged as members-only. */
|
||||
export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id)
|
||||
|
||||
/**
|
||||
* Which Teams may still be given an override, and whether the default is taken.
|
||||
*
|
||||
* Offering a Team that already has a row would only produce a save that silently
|
||||
* overwrote it, since the unique key is (platform, team).
|
||||
*/
|
||||
export function availableTargets(rows, teams) {
|
||||
const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id))
|
||||
return {
|
||||
hasDefault: rows.some(isDefaultRow),
|
||||
teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)),
|
||||
}
|
||||
}
|
||||
112
client/src/lib/teamVoice.js
Normal file
112
client/src/lib/teamVoice.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// What Admin → Teams → Voice channels decides (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// Extracted for the reason `teamIntegrations.js` is: the interesting parts are
|
||||
// decisions — when the panel refuses to let voice be switched on, how close the
|
||||
// guild is to running out of roles, what a row's state actually means to the
|
||||
// person reading it — and a decision written inline in JSX is one nothing can
|
||||
// assert on.
|
||||
//
|
||||
// **These rules MIRROR the server's and do not replace them.** The server refuses
|
||||
// to enable voice while the bot cannot manage channels and roles (422) whether or
|
||||
// not this file ever ran, and the reconciler applies the threshold and the grace
|
||||
// window regardless of what the screen says. What is here is so the screen agrees
|
||||
// with those answers before making the round trip.
|
||||
|
||||
/** Wording for each state the server can report on a row. */
|
||||
export const STATE_LABELS = {
|
||||
none: 'Not provisioned',
|
||||
active: 'Active',
|
||||
pending_removal: 'Scheduled for removal',
|
||||
error: 'Error',
|
||||
}
|
||||
|
||||
export const stateLabel = (state) => STATE_LABELS[state] || state || 'Unknown'
|
||||
|
||||
/**
|
||||
* Is the panel allowed to offer the enable switch?
|
||||
*
|
||||
* The preflight answers three separate questions and they fail differently: the
|
||||
* bot is not connected at all, it is connected but missing a permission, or it
|
||||
* could not be reached. An operator can act on each of those and they need
|
||||
* different actions, so the reason is passed through rather than flattened to a
|
||||
* boolean.
|
||||
*/
|
||||
export function enableBlockedReason(preflight) {
|
||||
if (!preflight) return 'The bot’s status is unknown.'
|
||||
if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.'
|
||||
if (preflight.missingPermissions && preflight.missingPermissions.length > 0) {
|
||||
return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.`
|
||||
}
|
||||
if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.'
|
||||
return null
|
||||
}
|
||||
|
||||
// Below this many free roles the panel starts saying so. Not a server rule and
|
||||
// deliberately not one: it is a warning, and the server's only hard behaviour is
|
||||
// to refuse the create that would exceed the cap.
|
||||
const HEADROOM_WARNING = 25
|
||||
|
||||
/**
|
||||
* How much room is left, and whether to say something about it.
|
||||
*
|
||||
* The 250-role cap is the ceiling this phase's shape brings with it. Access is a
|
||||
* per-Team role, so it is not "how big can a Team be" — the old overwrite design's
|
||||
* limit — but "how many Teams can have voice at all", and the difference matters
|
||||
* to an operator with sixty guilds on their shard. It is guild-wide and shared
|
||||
* with every role they created themselves, which is why the count comes from the
|
||||
* bot rather than from core's own rows.
|
||||
*/
|
||||
export function roleHeadroom(preflight) {
|
||||
if (!preflight || !preflight.roleCap) return null
|
||||
const used = Number(preflight.roleCount) || 0
|
||||
const cap = Number(preflight.roleCap)
|
||||
const free = Math.max(0, cap - used)
|
||||
return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 }
|
||||
}
|
||||
|
||||
/** How a row's grace window reads while it is running. */
|
||||
export function removalCountdown(row, now = new Date()) {
|
||||
if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null
|
||||
const ms = new Date(row.removeAfter).getTime() - now.getTime()
|
||||
if (ms <= 0) return 'due for removal on the next pass'
|
||||
const days = Math.floor(ms / 86400000)
|
||||
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
|
||||
const hours = Math.max(1, Math.round(ms / 3600000))
|
||||
return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the staff-role field an operator types.
|
||||
*
|
||||
* Comma-separated ids, because that is what a person copying role ids out of
|
||||
* Discord ends up with. Validated rather than filtered, mirroring the server: a
|
||||
* quietly dropped id is a settings screen showing a save that did not happen.
|
||||
*/
|
||||
export function parseStaffRoles(text) {
|
||||
const parts = String(text || '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part))
|
||||
return { roles: parts, invalid: bad }
|
||||
}
|
||||
|
||||
export const formatStaffRoles = (roles) => (roles || []).join(', ')
|
||||
|
||||
/**
|
||||
* The sentence under the enable switch, which changes meaning with the state.
|
||||
*
|
||||
* "Off" is not "nothing is provisioned": switching voice off suspends the
|
||||
* reconciler in BOTH directions and leaves existing channels in place, which is
|
||||
* deliberate — a checkbox must not delete structure in somebody's guild — but it
|
||||
* is also surprising unless the screen says so.
|
||||
*/
|
||||
export function statusSummary(settings, rows) {
|
||||
const provisioned = (rows || []).filter((row) => row.channelRef).length
|
||||
if (!settings || !settings.enabled) {
|
||||
return provisioned > 0
|
||||
? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.`
|
||||
: 'Off. No channels are provisioned.'
|
||||
}
|
||||
return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.`
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot } from './modules/registry.js'
|
||||
import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -18,8 +21,6 @@ publishSharedDependencies()
|
||||
// and namespace `uo`, so that the seam was exercised by real content from the
|
||||
// day it was built. That prediction paid out exactly as written: the extraction
|
||||
// deleted the registration and the hook it named, and SiteHeader was not touched.
|
||||
// There is nothing for core to register now — no core nav row carries a
|
||||
// `feature` — and the filter is a correct no-op until a module supplies one.
|
||||
|
||||
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
||||
//
|
||||
@@ -56,6 +57,49 @@ declareSlot('player.invite.accepted')
|
||||
// all three, and core's own fills had to go for it to be able to — the first
|
||||
// fill wins, and core registered first (§3.7).
|
||||
|
||||
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
|
||||
//
|
||||
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
|
||||
// tables, the sync, the access rules and the activity feed; it does not own the
|
||||
// word for one — a UO shard says guild, and the module that comes after it will
|
||||
// say clan. So core publishes no Team page and no Team nav row, and the module
|
||||
// that owns the vocabulary owns the page.
|
||||
//
|
||||
// The activity feed is the one piece of that page core cannot hand over: only
|
||||
// core can resolve whether this viewer is inside the Team, and the public/members
|
||||
// split is a security boundary. So the module declares the place and core fills
|
||||
// it. Registered here, applied at mount — `applyCoreFills` runs after every
|
||||
// module chunk has evaluated, which is the only moment a module-declared slot
|
||||
// exists to be filled.
|
||||
//
|
||||
// **Core offers a CONTRIBUTION and never names a slot.** The module that owns the
|
||||
// page says where each of these goes, in its own vocabulary, by asking for one on
|
||||
// `declareModuleSlot`. Naming the slots here instead — which is how this was first
|
||||
// written — meant core's Team content reached exactly one module: any other game
|
||||
// declaring a place under its own id got an empty page and no error, because a
|
||||
// fill nobody asked for is deliberately not an error. It also put a module id
|
||||
// inside core, in string literals `scripts/checkModuleIdentifiers.js` masks by
|
||||
// construction and so could never have caught.
|
||||
//
|
||||
// Offering something nothing asks for is still not an error: a deployment with no
|
||||
// game module installed asks for none of these, which is the mirror of an
|
||||
// unfilled slot rendering nothing.
|
||||
offerCoreFill('team.activity', TeamActivityFeed)
|
||||
|
||||
// The forum is core's for the same reason and goes wherever the module asked for
|
||||
// it — a SECOND place, in module-uo's case, rather than joining the feed in the
|
||||
// first: a slot takes one component (first fill wins), and stacking two unrelated
|
||||
// panels into one contribution would make the module unable to place them
|
||||
// separately on its own page. It also keeps the two independent — a deployment
|
||||
// with the forum switched off renders the feed exactly as before.
|
||||
offerCoreFill('team.forum', TeamForumPanel)
|
||||
|
||||
// And the notification control. A third contribution rather than a corner of the
|
||||
// feed for the same reason there were two: this is an action on the page and the
|
||||
// other two are content in it, and only the module can say where each belongs on
|
||||
// a page it owns.
|
||||
offerCoreFill('team.notify', TeamNotifyToggle)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
@@ -82,6 +126,10 @@ declareSlot('player.invite.accepted')
|
||||
// static deferred script, so this branch is the genuine "the event has already
|
||||
// been and gone" case and not a wrong guess about our own timing.
|
||||
function mount() {
|
||||
// Every module chunk has evaluated by now, so any slot a module declared is
|
||||
// present and core's pending fills can land. Must happen before the first
|
||||
// render: `extensionFor` is read during render and there is no subscription.
|
||||
applyCoreFills()
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
96
client/src/modules/TeamActivityFeed.jsx
Normal file
96
client/src/modules/TeamActivityFeed.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js'
|
||||
|
||||
// Core's Team activity feed, rendered into a slot a MODULE declares
|
||||
// (TEAMS.md Part 4, §3.4 as amended).
|
||||
//
|
||||
// **This is the inverted slot direction, and this component is why it exists.**
|
||||
// The feed is core's: core owns `team_activity`, writes the membership and rename
|
||||
// items into it, enforces the public/members split, and is the only thing that
|
||||
// can resolve whether this viewer is inside the Team. None of that is a module's
|
||||
// to reimplement. But the PAGE is the module's, because Teams is a contract
|
||||
// primitive and core does not own the word for one — a UO shard says guild, the
|
||||
// next game will say something else. So the module declares the place and core
|
||||
// puts the feed in it.
|
||||
//
|
||||
// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module
|
||||
// id — and core resolves the slug. A module never learns core's Team id and never
|
||||
// needs to: it names the thing the way it already names it.
|
||||
//
|
||||
// Everything here degrades to rendering nothing. A slot that throws is contained
|
||||
// by core's own boundary (Slot.jsx), but a slot that renders an error box would
|
||||
// still be core putting a defect on a page it does not own — so a failed fetch is
|
||||
// silence, not a message.
|
||||
|
||||
export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) {
|
||||
const { user } = useAuth()
|
||||
const [state, setState] = useState({ loading: true, feed: null, team: null })
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!externalId || !moduleId) {
|
||||
setState({ loading: false, feed: null, team: null })
|
||||
return undefined
|
||||
}
|
||||
// Two calls because the module names the Team its way and the feed is keyed
|
||||
// by core's slug. The lookup is core's job precisely so the module does not
|
||||
// have to hold core's identifiers.
|
||||
api.teamByExternalId(moduleId, externalId)
|
||||
.then(async (team) => {
|
||||
const feed = await api.teamActivity(team.slug, { limit })
|
||||
if (active) setState({ loading: false, feed, team })
|
||||
})
|
||||
.catch(() => { if (active) setState({ loading: false, feed: null, team: null }) })
|
||||
return () => { active = false }
|
||||
}, [externalId, moduleId, limit])
|
||||
|
||||
const { loading, feed, team } = state
|
||||
if (loading || !feed) return null
|
||||
|
||||
const days = groupByDay(feed.items || [])
|
||||
const note = team ? freshnessNote(team) : null
|
||||
const scopeNote = activityScopeNote(feed, Boolean(user))
|
||||
|
||||
// Nothing has happened and nothing to explain: render nothing rather than an
|
||||
// empty heading on someone else's page.
|
||||
if (days.length === 0 && !scopeNote) return null
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', marginBottom: 4 }}>
|
||||
Recent activity
|
||||
</h2>
|
||||
{note && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 12px' }}>{note.text}</p>
|
||||
)}
|
||||
|
||||
{days.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>Nothing has happened here yet.</p>
|
||||
)}
|
||||
|
||||
{days.map((day) => (
|
||||
<div key={day.key} style={{ marginBottom: 16 }}>
|
||||
<h3
|
||||
className="sans dim"
|
||||
style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}
|
||||
>
|
||||
{day.label}
|
||||
</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
|
||||
{day.items.map((item) => (
|
||||
<li key={item.id} className="sans" style={{ fontSize: '0.92rem', color: 'var(--ink)' }}>
|
||||
{item.summary}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{scopeNote && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>{scopeNote}</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
754
client/src/modules/TeamForumPanel.jsx
Normal file
754
client/src/modules/TeamForumPanel.jsx
Normal file
@@ -0,0 +1,754 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
|
||||
|
||||
// Core's Team forum, rendered into a second slot a MODULE declares
|
||||
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
|
||||
//
|
||||
// **Why the forum is core's content on a module's page.** Everything that decides
|
||||
// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
|
||||
// member/guest distinction — and none of it is a module's to reimplement. But
|
||||
// core does not own the word for a Team, so it publishes no Team page: the module
|
||||
// that says "guild" owns the page and declares a place on it, and core fills the
|
||||
// place. Same direction as the activity feed, same reason.
|
||||
//
|
||||
// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
|
||||
// thread needs to be linkable, and core cannot mount a route for it — the route
|
||||
// belongs to the module's page. `?thread=12` gives a shareable URL that works
|
||||
// under whatever path the module chose, with no route of core's anywhere in it,
|
||||
// and the browser's back button behaves. That is the whole reason this component
|
||||
// holds a list view and a detail view rather than being two components.
|
||||
//
|
||||
// **The image mode is published so this can draw the right composer — never to
|
||||
// decide what renders.** Post bodies arrive already rendered by the server under
|
||||
// the current policy (§5.5.3); the mode is read here only to show or hide an
|
||||
// upload control that would otherwise 404. If the two ever disagree, the server
|
||||
// is right.
|
||||
//
|
||||
// **Phase 5 added discussion, and with it three capabilities this file must not
|
||||
// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are
|
||||
// computed on the server and read here. In particular the edit window is a
|
||||
// server decision twice over — the read path stamps `canEdit`/`editableUntil` and
|
||||
// the write re-derives it — because a time-bounded permission must not take its
|
||||
// clock from the party it bounds. What this file does with `editableUntil` is
|
||||
// stop OFFERING an edit whose deadline has passed while the page sat open; it
|
||||
// never grants one.
|
||||
//
|
||||
// Like the feed, everything here degrades to rendering nothing. A 404 from the
|
||||
// thread list is the ordinary case — the forum is switched off, or this viewer
|
||||
// has no access — and putting an error box on a page core does not own would be
|
||||
// core reporting its own absence as a defect on someone else's surface.
|
||||
|
||||
export default function TeamForumPanel({ externalId, moduleId }) {
|
||||
const { user } = useAuth()
|
||||
const { settings } = useSite()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [team, setTeam] = useState(null)
|
||||
const [state, setState] = useState({ loading: true, forum: null })
|
||||
const [thread, setThread] = useState(null)
|
||||
const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null
|
||||
|
||||
const openThreadId = params.get('thread')
|
||||
const imageMode = settings?.teams_forum_images || 'disabled'
|
||||
const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
|
||||
|
||||
const loadThreads = useCallback(async (slug) => {
|
||||
try {
|
||||
setState({ loading: false, forum: await api.teamForumThreads(slug) })
|
||||
} catch {
|
||||
setState({ loading: false, forum: null })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadThread = useCallback(async (slug, id) => {
|
||||
try {
|
||||
setThread(await api.teamForumThread(slug, id))
|
||||
} catch {
|
||||
setThread(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
// An anonymous visitor has no forum by definition — every route is behind
|
||||
// requireAuth — so skip the two calls rather than provoking a 401 per page.
|
||||
if (!externalId || !moduleId || !user || !forumsEnabled) {
|
||||
setState({ loading: false, forum: null })
|
||||
return undefined
|
||||
}
|
||||
// The module names the Team its own way; core resolves that to a slug. Same
|
||||
// two-call shape as the activity feed, and for the same reason: a module
|
||||
// never has to hold core's identifiers.
|
||||
api.teamByExternalId(moduleId, externalId)
|
||||
.then(async (found) => {
|
||||
if (!active) return
|
||||
setTeam(found)
|
||||
await loadThreads(found.slug)
|
||||
})
|
||||
.catch(() => { if (active) setState({ loading: false, forum: null }) })
|
||||
return () => { active = false }
|
||||
}, [externalId, moduleId, user, forumsEnabled, loadThreads])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!team || !openThreadId) {
|
||||
setThread(null)
|
||||
return undefined
|
||||
}
|
||||
api.teamForumThread(team.slug, openThreadId)
|
||||
.then((t) => { if (active) setThread(t) })
|
||||
.catch(() => { if (active) setThread(null) })
|
||||
return () => { active = false }
|
||||
}, [team, openThreadId])
|
||||
|
||||
const openThread = (id) => {
|
||||
const next = new URLSearchParams(params)
|
||||
if (id == null) next.delete('thread')
|
||||
else next.set('thread', String(id))
|
||||
setParams(next)
|
||||
}
|
||||
|
||||
const { loading, forum } = state
|
||||
if (loading || !forum) return null
|
||||
|
||||
if (openThreadId && thread) {
|
||||
return (
|
||||
<ThreadView
|
||||
slug={team.slug}
|
||||
thread={thread}
|
||||
canModerate={forum.canModerate}
|
||||
imageMode={imageMode}
|
||||
onBack={() => openThread(null)}
|
||||
onChanged={() => loadThread(team.slug, thread.id)}
|
||||
onModerate={async (action) => {
|
||||
await api.teamForumModerate(team.slug, thread.id, { action })
|
||||
await loadThreads(team.slug)
|
||||
openThread(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
|
||||
Forum
|
||||
</h2>
|
||||
{!composing && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{/*
|
||||
Two buttons, because phase 5 split one capability in two. `canPost`
|
||||
means "may open a discussion" and every participant may — including a
|
||||
granted guest with no game character, which is path 3 doing its job.
|
||||
`canAnnounce` is the leader-only half.
|
||||
*/}
|
||||
{forum.canPost && (
|
||||
<button type="button" className="pill" onClick={() => setComposing('discussion')}>
|
||||
Start a discussion
|
||||
</button>
|
||||
)}
|
||||
{forum.canAnnounce && (
|
||||
<button type="button" className="pill" onClick={() => setComposing('announcement')}>
|
||||
Post an announcement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{composing && (
|
||||
<Composer
|
||||
slug={team.slug}
|
||||
type={composing}
|
||||
imageMode={imageMode}
|
||||
onCancel={() => setComposing(null)}
|
||||
onPosted={async () => {
|
||||
setComposing(null)
|
||||
await loadThreads(team.slug)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forum.threads.length === 0 && !composing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
||||
Nothing has been posted here yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{forum.canModerate && <GuestManager slug={team.slug} />}
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
|
||||
{forum.threads.map((t) => (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => openThread(t.id)}
|
||||
style={{
|
||||
background: 'none', border: 0, padding: 0, cursor: 'pointer',
|
||||
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
|
||||
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
|
||||
<strong>{t.title}</strong>
|
||||
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
|
||||
{threadSummary(t)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The leader's grant control — §2.5 path 3, exercised by a leader rather than by
|
||||
* staff.
|
||||
*
|
||||
* Worth being explicit about what this admits someone to and what it does not: a
|
||||
* grant may name ANY account, including one with no linked game character, and it
|
||||
* writes nothing but the grants ledger. A guest here never appears on the roster,
|
||||
* never counts towards the Team's membership, and never becomes eligible for a
|
||||
* Discord role — an integration cannot verify that an unlinked account is a real
|
||||
* game member, so it must not hand that account a privilege somewhere
|
||||
* impersonation has consequences.
|
||||
*
|
||||
* A leader is capped; staff are not. The cap is shown rather than only enforced,
|
||||
* because a leader who hits a limit they were never told about reads it as a bug.
|
||||
*/
|
||||
function GuestManager({ slug }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [data, setData] = useState(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await api.teamGrantList(slug))
|
||||
} catch {
|
||||
setData(null)
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
useEffect(() => { if (open) load() }, [open, load])
|
||||
|
||||
const add = async (event) => {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamGrantAdd(slug, { username })
|
||||
setUsername('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not grant access')
|
||||
}
|
||||
}
|
||||
|
||||
const revoke = async (userId) => {
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamGrantRevoke(slug, userId)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not revoke that')
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" className="pill" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
|
||||
Forum guests
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
|
||||
<button type="button" className="pill" onClick={() => setOpen(false)}>Close</button>
|
||||
</header>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
|
||||
Guests read and post in this forum without being members of the Team. They do not appear on the
|
||||
roster and are not counted as members.
|
||||
{data?.cap ? ` Up to ${data.cap} at a time.` : ''}
|
||||
</p>
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
|
||||
{(data?.guests || []).map((g) => (
|
||||
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
|
||||
<span>{g.username}</span>
|
||||
<button type="button" className="pill" onClick={() => revoke(g.userId)}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
{data && data.guests.length === 0 && (
|
||||
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Account name"
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sq">Add</button>
|
||||
</form>
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
|
||||
// A clock that ticks, so an edit control whose deadline passed while the page
|
||||
// sat open goes away instead of becoming a button that fails. It only ever
|
||||
// REMOVES an offer — the server decides whether an edit happens, and re-derives
|
||||
// the window from created_at when it does.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
|
||||
← All threads
|
||||
</button>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
|
||||
{thread.title}
|
||||
</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
||||
{thread.type === 'announcement' ? 'Announcement · ' : ''}
|
||||
{thread.author}
|
||||
{thread.authorDeleted && ' (account removed)'}
|
||||
{thread.locked && ' · locked'}
|
||||
</p>
|
||||
|
||||
{thread.posts.map((post) => (
|
||||
<PostView
|
||||
key={post.id}
|
||||
slug={slug}
|
||||
post={post}
|
||||
canModerate={canModerate}
|
||||
now={now}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/*
|
||||
`canReply` is the server's answer to "does this thread take replies right
|
||||
now", and it folds together the two reasons it might not: an announcement
|
||||
takes none by TYPE, and a locked thread takes none by STATE. Both are
|
||||
reported separately above so the reader can see which.
|
||||
*/}
|
||||
{thread.canReply && !replying && (
|
||||
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
{thread.canReply && replying && (
|
||||
<ReplyBox
|
||||
slug={slug}
|
||||
threadId={thread.id}
|
||||
imageMode={imageMode}
|
||||
onCancel={() => setReplying(false)}
|
||||
onPosted={async () => {
|
||||
setReplying(false)
|
||||
await onChanged()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!thread.canReply && thread.locked && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
|
||||
This thread is locked. Nobody can reply to it, including staff — a moderator who wants the
|
||||
last word unlocks it first, which leaves a record.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
<ReportControl
|
||||
slug={slug}
|
||||
targetType="team_forum_thread"
|
||||
targetId={thread.id}
|
||||
label="Report this thread"
|
||||
/>
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
||||
{thread.pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
|
||||
{thread.locked ? 'Unlock' : 'Lock'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One post, with whatever this reader may do to it.
|
||||
*
|
||||
* Every capability shown here was decided by the server and is read, not
|
||||
* computed: `canEdit` and `editableUntil` come stamped on the post, and
|
||||
* `canModerate` on the thread. The one local judgement is whether an
|
||||
* already-granted edit window has since elapsed, which can only take an offer
|
||||
* away.
|
||||
*/
|
||||
function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now])
|
||||
|
||||
const save = async (event) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamForumEditPost(slug, post.id, { body })
|
||||
setEditing(false)
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const moderate = async (action) => {
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamForumModeratePost(slug, post.id, { action })
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not do that')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article style={{ marginBottom: 16 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
|
||||
{post.author}
|
||||
{post.authorDeleted && ' (account removed)'}
|
||||
{post.editedAt && ' · edited'}
|
||||
{post.status === 'hidden' && ' · hidden'}
|
||||
</p>
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
|
||||
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{/*
|
||||
Sanitised on write with the forum's own profile, rendered server-side
|
||||
under the operator's image policy, and re-sanitised here — the same
|
||||
defence-in-depth every other body-HTML surface on this site applies
|
||||
(FiveOnFriday, NewsletterIssue, the rich-text block).
|
||||
|
||||
`ADD_ATTR: ['referrerpolicy']` is load-bearing and not a preference.
|
||||
DOMPurify's default allowlist carries `loading` but NOT
|
||||
`referrerpolicy`, so a plain sanitize() call silently strips the one
|
||||
attribute that limits what a remote embed leaks to the host serving it
|
||||
— the privacy property the admin help text promises an operator. The
|
||||
<img> itself is core's own output with a fixed attribute set, so
|
||||
nothing here is widening what an author can write.
|
||||
*/}
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div
|
||||
className="prose"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||
|
||||
{!editing && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{stillEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
onClick={() => { setBody(stripToText(post.body)); setEditing(true) }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{/* Reporting your own post is pointless rather than harmful, but
|
||||
offering it reads as an invitation to misunderstand the control. */}
|
||||
{!post.mine && (
|
||||
<ReportControl
|
||||
slug={slug}
|
||||
targetType="team_forum_post"
|
||||
targetId={post.id}
|
||||
label="Report"
|
||||
/>
|
||||
)}
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The report control — the first user-facing report flow this site has ever had.
|
||||
*
|
||||
* **It goes to site staff, and it says so.** The gap it closes is that leaders
|
||||
* moderate their own Team's forum and a Team's leaders are exactly the people who
|
||||
* will not report their own Team, so telling a member where the report lands is
|
||||
* not reassurance copy — it is the whole reason the control is worth using in a
|
||||
* Team whose leadership is the problem.
|
||||
*
|
||||
* A report changes nothing about the content, and the confirmation says that too,
|
||||
* because a member who expects a post to vanish and watches it stay will report
|
||||
* it again.
|
||||
*/
|
||||
function ReportControl({ slug, targetType, targetId, label }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [reason, setReason] = useState('abuse')
|
||||
const [detail, setDetail] = useState('')
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamForumReport(slug, { targetType, targetId, reason, detail: detail || undefined })
|
||||
setDone(true)
|
||||
setOpen(false)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not send that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<span className="sans dim" style={{ fontSize: '0.8rem' }}>
|
||||
Reported to site staff.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" className="pill" onClick={() => setOpen(true)}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
style={{
|
||||
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
|
||||
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
|
||||
This goes to <strong>site staff</strong>, not to this Team’s leaders. Reporting does not
|
||||
hide or change anything — it asks a staffer to look.
|
||||
</p>
|
||||
<label className="sans" style={{ fontSize: '0.85rem' }}>
|
||||
Reason
|
||||
{' '}
|
||||
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||
{REPORT_REASONS.map(([value, text]) => (
|
||||
<option key={value} value={value}>{text}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={detail}
|
||||
onChange={(e) => setDetail(e.target.value)}
|
||||
placeholder="Anything a staffer should know (optional)"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
|
||||
<button type="button" className="pill" onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** A reply to an open discussion thread. */
|
||||
function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamForumReply(slug, threadId, { body })
|
||||
await onPosted()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not post that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
{imageMode === 'uploads' && (
|
||||
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
|
||||
)}
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
|
||||
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The upload control, shared by both composers.
|
||||
*
|
||||
* The URL goes into the BODY as text, never as an `<img>` tag. The author never
|
||||
* writes markup here — core decides at render time whether a URL becomes a
|
||||
* picture, which is what makes the operator's image policy enforceable rather
|
||||
* than decorative.
|
||||
*/
|
||||
function ImageAttacher({ slug, onAttached, onError }) {
|
||||
const attach = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const { url } = await api.teamForumUpload(slug, file)
|
||||
onAttached(url)
|
||||
} catch (err) {
|
||||
onError(err.message || 'Could not upload that')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
Attach an image: <input type="file" accept="image/*" onChange={attach} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Composer({ slug, type, imageMode, onCancel, onPosted }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isAnnouncement = type === 'announcement'
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
// `type` is always sent explicitly. The server defaults an absent one to
|
||||
// `announcement` so that a phase-4 client keeps meaning what it meant, and
|
||||
// relying on that default here would make a discussion depend on a
|
||||
// compatibility shim.
|
||||
await api.teamForumPost(slug, { type, title, body })
|
||||
await onPosted()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not post that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
maxLength={200}
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={isAnnouncement
|
||||
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
|
||||
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
{isAnnouncement && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
|
||||
Announcements cannot be replied to.
|
||||
</p>
|
||||
)}
|
||||
{imageMode === 'uploads' && (
|
||||
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
|
||||
)}
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
|
||||
// Core's per-Team notification control, rendered into a THIRD slot a module
|
||||
// declares (TEAMS.md §6.3, phase 6).
|
||||
//
|
||||
// **Why this is a slot at all, and why it is the third one.** Teams have no core
|
||||
// page — the module that owns the vocabulary owns the page — so a control that
|
||||
// acts on one Team has nowhere of core's to live. The feed and the forum go below
|
||||
// the module's roster; this goes above it, because muting a guild is an action ON
|
||||
// the page rather than more content in it, and that is exactly the placement
|
||||
// decision a module cannot make if core stacks everything into one fill.
|
||||
//
|
||||
// **It renders nothing for a viewer who is not in the Team**, including anonymous
|
||||
// ones, and that is a privacy property rather than a tidiness one: whether a
|
||||
// notification preference EXISTS for a Team answers "is this person in it", and
|
||||
// the guild page is public. The server decides — the preference list only contains
|
||||
// Teams the caller may be notified about — and this file never infers membership
|
||||
// from anything it can see on the page.
|
||||
//
|
||||
// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
|
||||
// on the account screen, where the catalog does; the thing that could not be
|
||||
// expressed before phase 6 is "I am in five Teams and want notifications from
|
||||
// one", and that is the only question this control asks.
|
||||
|
||||
export default function TeamNotifyToggle({ externalId, moduleId }) {
|
||||
const { user } = useAuth()
|
||||
const [state, setState] = useState({ loading: true, team: null, pref: null })
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
|
||||
// guild page rendering a public roster should not put an authenticated
|
||||
// request on the wire for every visitor.
|
||||
if (!user) return setState({ loading: false, team: null, pref: null })
|
||||
try {
|
||||
const team = await api.teamByExternalId(moduleId, externalId)
|
||||
const { teams } = await api.teamNotificationPrefs()
|
||||
const pref = (teams || []).find((t) => t.teamId === team.id) || null
|
||||
setState({ loading: false, team, pref })
|
||||
} catch {
|
||||
// Same rule as the feed and the forum: this is core's content on a page
|
||||
// core does not own, so a failure renders nothing rather than putting an
|
||||
// error box on somebody else's surface.
|
||||
setState({ loading: false, team: null, pref: null })
|
||||
}
|
||||
}, [externalId, moduleId, user])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const { loading, pref } = state
|
||||
if (loading || !pref) return null
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
// Optimistic, and reconciled from the server's echo rather than assumed: a
|
||||
// PUT that silently dropped the entry (a Team left in another tab) must not
|
||||
// leave the control claiming a state the server does not hold.
|
||||
const next = { ...pref, muted: !pref.muted }
|
||||
setState((s) => ({ ...s, pref: next }))
|
||||
try {
|
||||
const { teams } = await api.setTeamNotificationPrefs([
|
||||
{ teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
|
||||
])
|
||||
const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
|
||||
if (echoed) setState((s) => ({ ...s, pref: echoed }))
|
||||
} catch {
|
||||
setState((s) => ({ ...s, pref }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
margin: '10px 0 0',
|
||||
fontSize: '0.84rem',
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={toggle} disabled={busy} className="btn btn-sq">
|
||||
{pref.muted ? 'Unmute notifications' : 'Mute notifications'}
|
||||
</button>
|
||||
<span className="dim">
|
||||
{pref.muted
|
||||
? 'You get no notifications about this team.'
|
||||
: 'You get notifications about this team.'}
|
||||
</span>
|
||||
{/* The one link off this control, because "mute" is a blunt answer to a
|
||||
question the account screen asks properly — which streams, and whether
|
||||
email is on at all. */}
|
||||
<Link to="/account/notifications" className="dim">All notification settings</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -135,6 +135,120 @@ export function declareSlot(name) {
|
||||
slots.set(name, { Component: null, filledBy: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* The contributions core has for a module-declared slot.
|
||||
*
|
||||
* **Core offers a CONTRIBUTION, not a slot name, and that is the whole of why
|
||||
* this list exists.** The first cut of the inverted direction had core fill three
|
||||
* literal names — `uo.guild.detail` and its two siblings — which worked for
|
||||
* exactly one module and silently did nothing for any other: a second game
|
||||
* declaring `clan.detail` under its own id got an empty page and no error,
|
||||
* because "a fill for a slot nobody declared is not an error" is the rule that
|
||||
* makes an unknown name invisible. It also put a module identifier in core, in
|
||||
* three string literals `scripts/checkModuleIdentifiers.js` cannot see, since it
|
||||
* masks string bodies by construction.
|
||||
*
|
||||
* So the module says WHERE (its own slot, in its own vocabulary) and WHICH of
|
||||
* core's contributions goes there. Core never names a module id.
|
||||
*
|
||||
* Adding a member here is a **minor** MODULE_API bump. Requesting one that is not
|
||||
* here THROWS at the declaration, deliberately: unlike an unfilled slot, an
|
||||
* unknown contribution is always a typo or a version skew — core's list is fixed
|
||||
* at build time and a module's `coreApi` range has already been checked — and the
|
||||
* failure it would otherwise produce is a page that renders empty forever.
|
||||
*/
|
||||
export const CORE_CONTRIBUTIONS = Object.freeze({
|
||||
/** The Team activity feed. Core's because only core can resolve the public/members split on it. */
|
||||
'team.activity': true,
|
||||
/** The Team forum panel. Core's because membership and manual grants are core's rules. */
|
||||
'team.forum': true,
|
||||
/** The per-Team notification control. Core's because it resolves whether the viewer is in the Team. */
|
||||
'team.notify': true,
|
||||
})
|
||||
|
||||
/**
|
||||
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
|
||||
*
|
||||
* Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
|
||||
* the page and a module contributes to it, which is right for the footer and the
|
||||
* admin user detail. Teams is the other shape: **Teams is a contract primitive,
|
||||
* not a surface.** Core owns the tables, the sync, the access rules and the
|
||||
* activity feed; it does not own the vocabulary — a UO shard calls them guilds
|
||||
* and the next game will call them something else — so the PAGE is the module's
|
||||
* and the content core contributes to it is core's.
|
||||
*
|
||||
* Without this, core would have to publish a `/teams` page under a word it
|
||||
* invented, next to the module's own Guilds page saying the same thing twice.
|
||||
*
|
||||
* A module namespaces its slot under its own id (`uo.guild.detail`), which is
|
||||
* what stops two modules colliding and what makes the owner readable at the fill
|
||||
* site. The namespace is enforced rather than conventional.
|
||||
*
|
||||
* **`options.core` names which of core's contributions belongs in that place.**
|
||||
* It is optional — a module may declare a slot it fills itself, or one it keeps
|
||||
* empty for now — and it is the only thing that gets core's content into the
|
||||
* page. The place name stays the module's own word; the contribution is core's.
|
||||
*
|
||||
* **Ordering is why this is a separate call and not just `declareSlot` exposed
|
||||
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
|
||||
* are deferred and injected after core's), so at the moment core would like to
|
||||
* fill one of these, it does not exist yet. Core therefore offers its
|
||||
* contributions through `offerCoreFill` below, applied after every module chunk
|
||||
* has evaluated — see main.jsx.
|
||||
*/
|
||||
export function declareModuleSlot(id, name, options = {}) {
|
||||
if (!name.startsWith(`${id}.`)) {
|
||||
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
||||
}
|
||||
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
||||
const contribution = options.core ?? null
|
||||
if (contribution !== null && !Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
||||
throw new Error(
|
||||
`declareModuleSlot: "${name}" asks for core contribution "${contribution}", which core does not ` +
|
||||
`offer. Known: ${Object.keys(CORE_CONTRIBUTIONS).join(', ')}.`,
|
||||
)
|
||||
}
|
||||
slots.set(name, { Component: null, filledBy: null, declaredBy: id, wants: contribution })
|
||||
}
|
||||
|
||||
// Core's pending contributions, applied once every module chunk has evaluated.
|
||||
// Kept as a list rather than applied eagerly because no module-declared slot
|
||||
// exists when core offers — see the ordering note above.
|
||||
const coreFills = []
|
||||
|
||||
/**
|
||||
* Core: "here is my <contribution>, for whichever module asked for it."
|
||||
*
|
||||
* Deliberately not an error when nothing asked. A deployment with no game module
|
||||
* installed asks for none of these, and core offering content for a page that
|
||||
* does not exist is the ordinary case rather than a misconfiguration — the mirror
|
||||
* of an unfilled slot rendering nothing.
|
||||
*
|
||||
* More than one slot may ask for the same contribution, and each gets it. Core
|
||||
* has no reason to care how many places a module wants its feed in, and refusing
|
||||
* the second would be core making a layout decision on a page it does not own.
|
||||
*/
|
||||
export function offerCoreFill(contribution, Component) {
|
||||
if (!Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
||||
throw new Error(`offerCoreFill: "${contribution}" is not in CORE_CONTRIBUTIONS`)
|
||||
}
|
||||
if (typeof Component !== 'function') throw new Error(`offerCoreFill: ${contribution} is not a component`)
|
||||
coreFills.push([contribution, Component])
|
||||
}
|
||||
|
||||
/** Apply core's contributions. Called once from main.jsx, after module chunks have run. */
|
||||
export function applyCoreFills() {
|
||||
for (const [contribution, Component] of coreFills) {
|
||||
for (const entry of slots.values()) {
|
||||
if (entry.wants !== contribution) continue
|
||||
if (entry.filledBy) continue // a module already claimed it; first fill wins
|
||||
entry.Component = Component
|
||||
entry.filledBy = 'core'
|
||||
}
|
||||
}
|
||||
coreFills.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a declared slot with a component.
|
||||
*
|
||||
@@ -207,6 +321,7 @@ export function _reset() {
|
||||
nav[area].length = 0
|
||||
}
|
||||
providers.clear()
|
||||
coreFills.length = 0
|
||||
// Declarations go too, unlike the server's, where a slot is declared once at
|
||||
// require time by the router that owns it. Core declares its slots in
|
||||
// main.jsx — the one file no test loads — so on this side there is nothing
|
||||
@@ -224,6 +339,8 @@ export const registry = {
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
registerExtension,
|
||||
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
|
||||
declareModuleSlot,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { MODULE_API_VERSION } from './version.js'
|
||||
import PublicLayout from '../components/PublicLayout.jsx'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
||||
import Slot from './Slot.jsx'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
@@ -66,6 +67,13 @@ const ui = {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
// The ninth member, for the INVERTED slot direction (TEAMS.md Part 3). A
|
||||
// module that declares a slot on its own page needs the same component core
|
||||
// renders its own with — the error boundary in particular, since the thing
|
||||
// being contained here is CORE's content failing inside the MODULE's page.
|
||||
// Shared rather than reimplemented for the reason the whole kit exists: two
|
||||
// boundaries with different behaviour would be two bugs.
|
||||
Slot,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
|
||||
// changed yet: the two client additions the version covers are the `team.overview`
|
||||
// and `team.member.row` slots, and a slot can only be declared by the page that
|
||||
// hosts it, which lands with the Team pages in phase 3. This file bumps anyway,
|
||||
// for the reason at the top — the two halves state ONE version, and a module
|
||||
// declares one `coreApi` range against both.
|
||||
//
|
||||
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
|
||||
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
|
||||
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
|
||||
@@ -38,4 +45,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.5.0'
|
||||
export const MODULE_API_VERSION = '1.6.0'
|
||||
|
||||
@@ -76,6 +76,17 @@ export const NAV = [
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
|
||||
// because a staffer working a queue should have one place to work — and
|
||||
// because the queue is deliberately generic, so the next thing that can
|
||||
// be reported arrives as a row rather than as another nav entry.
|
||||
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
// Moderation rather than System: the screen's daily job is the
|
||||
// reserved-name review queue, which is moderator work. The three actions
|
||||
// that publish a game-written name are gated to admins server-side, so a
|
||||
// moderator reaching this screen is correct — what they do here is file a
|
||||
// request (TEAMS.md §2.9).
|
||||
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,6 +145,7 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/moderation/appeals': 'Appeals',
|
||||
'/admin/moderation/reports': 'Reports',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
|
||||
310
client/src/routes/admin/views/ContentReports.jsx
Normal file
310
client/src/routes/admin/views/ContentReports.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import Modal from '../../../components/Modal.jsx'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// The member-raised content-report queue (TEAMS.md §5.6).
|
||||
//
|
||||
// **This is the only view of this queue, and that is the design.** The gap §5.6
|
||||
// exists to close has a specific shape: leaders moderate their own Team's forum,
|
||||
// and a Team's leaders are exactly the people who will not report their own Team.
|
||||
// A leader-visible queue would route a complaint about a leader back to that
|
||||
// leader. Org lead, 2026-08-18: reports are **site administration only**. If a
|
||||
// leader-facing view is ever wanted it is a design decision, not a component.
|
||||
//
|
||||
// It sits beside Appeals rather than under Teams because a staffer working a
|
||||
// queue should have one place to work — and because `target_type` is deliberately
|
||||
// open-ended, so the next consumer (a wiki page, a news comment) arrives as a new
|
||||
// row here rather than as a new screen.
|
||||
//
|
||||
// **Handling a report is bookkeeping about the REPORT, not moderation of the
|
||||
// content.** Acting on the content itself is the ordinary forum moderation
|
||||
// control, or a site-wide sanction against the account. Keeping those separate is
|
||||
// what stops "report" from becoming a way for any member to hide anything, so
|
||||
// this screen deliberately offers no hide/delete button of its own.
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: 'open_work', label: 'Open work', param: undefined },
|
||||
{ key: 'open', label: 'Open', param: 'open' },
|
||||
{ key: 'reviewing', label: 'Reviewing', param: 'reviewing' },
|
||||
{ key: 'actioned', label: 'Actioned', param: 'actioned' },
|
||||
{ key: 'dismissed', label: 'Dismissed', param: 'dismissed' },
|
||||
{ key: 'all', label: 'All', param: 'all' },
|
||||
]
|
||||
|
||||
const STATUS_STYLE = {
|
||||
open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
|
||||
reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
|
||||
actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
|
||||
dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
|
||||
}
|
||||
const STATUS_LABEL = {
|
||||
open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed',
|
||||
}
|
||||
|
||||
const REASON_LABEL = {
|
||||
spam: 'Spam',
|
||||
abuse: 'Abuse',
|
||||
sexual: 'Sexual',
|
||||
illegal: 'Illegal',
|
||||
impersonation: 'Impersonation',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
const bytes = (n) => {
|
||||
if (!n && n !== 0) return ''
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* What was reported, rendered from the row the queue already resolved.
|
||||
*
|
||||
* Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size
|
||||
* and sniffed type without hunting, and the server attaches all of it in three
|
||||
* batched reads. A `null` target is a target that has since been hard-deleted,
|
||||
* and the row still shows — "somebody reported this and by the time we looked it
|
||||
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
|
||||
* member deleting their own content the moment it is reported.
|
||||
*/
|
||||
function TargetCell({ report }) {
|
||||
const t = report.target
|
||||
if (!t) {
|
||||
return (
|
||||
<span style={{ color: 'var(--muted)' }}>
|
||||
{report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (t.kind === 'upload') {
|
||||
return (
|
||||
<span>
|
||||
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
|
||||
{t.deleted && ' · removed'}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (t.kind === 'thread') {
|
||||
return (
|
||||
<span>
|
||||
<strong>{t.title}</strong>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.type} by {t.author || 'unknown'}
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{t.excerpt || <em className="dim">(no text)</em>}
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.author || 'unknown'} in “{t.threadTitle}”
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContentReports() {
|
||||
const [tab, setTab] = useState('open_work')
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const [handling, setHandling] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
|
||||
const { loading, error, data } = useAsync(
|
||||
() => api.admin.contentReports({ status: activeTab.param }),
|
||||
[tab, tick],
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load reports." />
|
||||
|
||||
const rows = data?.reports || []
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
|
||||
Reports raised by members about Team forum content. They come to site staff and are not visible
|
||||
to a Team’s own leaders — a leader moderates their own forum, so a report about a leader
|
||||
has to reach someone above them. Handling a report records a decision about the report; hiding
|
||||
or removing the content itself is done from the forum, or as a sanction against the account.
|
||||
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className="pill"
|
||||
style={tab === t.key ? activePill : undefined}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
className="sans"
|
||||
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Reported content</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Reporter</th>
|
||||
<th className="adm-th">Age</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={7} style={muted}>
|
||||
No reports match this filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 340 }}>
|
||||
<TargetCell report={r} />
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
|
||||
<td className="adm-td dim">{r.reporter}</td>
|
||||
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
|
||||
{r.handledBy && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
|
||||
{r.handledBy}
|
||||
{r.handledNote ? ` — ${r.handledNote}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
onClick={() => setHandling(r)}
|
||||
className="btn btn-primary btn-sq"
|
||||
style={{ padding: '5px 12px', fontSize: '0.82rem' }}
|
||||
>
|
||||
Handle
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{handling && (
|
||||
<HandleModal
|
||||
report={handling}
|
||||
onCancel={() => setHandling(null)}
|
||||
onDone={() => {
|
||||
setHandling(null)
|
||||
setNotice({ text: 'Report updated.', tone: 'ok' })
|
||||
reload()
|
||||
}}
|
||||
onError={(message) => setNotice({ text: message, tone: 'error' })}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a decision about a report.
|
||||
*
|
||||
* The note is optional and worth writing: every transition is audited, dismissals
|
||||
* included, and the note is what the next staffer to see a repeat report about the
|
||||
* same content reads to find out why the last one was closed.
|
||||
*/
|
||||
function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned')
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.admin.handleContentReport(report.id, { status, note: note || undefined })
|
||||
onDone()
|
||||
} catch (err) {
|
||||
onError(err.message || 'Could not update that report.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Report #${report.id}`}
|
||||
onClose={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<button className="pill" onClick={onCancel}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||||
This records a decision about the report. It does not hide, delete or restore the content —
|
||||
do that from the forum itself, or against the account.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setStatus(value)}
|
||||
className="pill"
|
||||
style={status === value ? activePill : undefined}
|
||||
>
|
||||
{STATUS_LABEL[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span className="field-label">Note (optional)</span>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Why this was actioned or dismissed — the next staffer to see a repeat report reads this."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
|
||||
const muted = { color: 'var(--muted)' }
|
||||
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import EmailDelivery from './EmailDelivery.jsx'
|
||||
import TeamForumSettings from './TeamForumSettings.jsx'
|
||||
|
||||
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||||
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TeamForumSettings />
|
||||
|
||||
<EmailDelivery />
|
||||
</section>
|
||||
)
|
||||
|
||||
276
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
276
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
|
||||
// and the acknowledgement.
|
||||
//
|
||||
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
|
||||
// same reason EmailDelivery is its own: one of these settings has a server-side
|
||||
// PRECONDITION and a confirmation flow, and a control with a precondition inside a
|
||||
// generic list of key/value inputs is one whose behaviour nobody reading that list
|
||||
// would predict.
|
||||
//
|
||||
// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
|
||||
// 'uploads'` with 400 unless the same request carries the acknowledgement version,
|
||||
// and it does so whether or not this dialog was ever rendered. What is here is how
|
||||
// the gate is PRESENTED — the wording an operator agrees to, and the recording of
|
||||
// which version they agreed to.
|
||||
|
||||
// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
|
||||
// explains what the setting is, which is a different job from the confirmation.
|
||||
const HELP_TEXT = [
|
||||
'Image uploads are disabled by default.',
|
||||
'Enabling uploads allows users to store files on infrastructure that you control.',
|
||||
'By enabling this feature, you acknowledge that you are responsible for:',
|
||||
]
|
||||
const HELP_BULLETS = [
|
||||
'Moderating uploaded content',
|
||||
'Managing storage and backups',
|
||||
'Complying with applicable laws and regulations',
|
||||
'Establishing policies for your community',
|
||||
]
|
||||
const HELP_TAIL = [
|
||||
'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
|
||||
+ ' is stored on your own infrastructure.',
|
||||
// Addition 1 — the reassuring counterpart, and the reason the attribution table
|
||||
// in §5.5.4 exists at all.
|
||||
'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
|
||||
// Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
|
||||
// not the same as game membership, so this genuinely surprises.
|
||||
'Anyone with access to a team forum can upload, including members granted access manually who have'
|
||||
+ ' no linked game account.',
|
||||
]
|
||||
|
||||
// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
|
||||
// stored in that mode — but the operator's server is still doing the displaying.
|
||||
const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitor’s browser directly from the'
|
||||
+ ' site hosting them. That site can see your visitors’ IP addresses, and you do not control whether'
|
||||
+ ' the image changes or disappears.'
|
||||
|
||||
// §5.5.5(b). Shown only when changing the mode TO uploads.
|
||||
const DIALOG_CHECKS = [
|
||||
'I understand that uploaded files will be stored on infrastructure that I control.',
|
||||
'I understand that I am responsible for community moderation policies on this installation.',
|
||||
]
|
||||
// Addition 2 — the expectation gap most likely to bite. An operator who turns
|
||||
// uploads off because of a problem will assume the problem goes with it.
|
||||
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
|
||||
+ ' already uploaded — remove those from the forum moderation tools.'
|
||||
|
||||
const MODES = [
|
||||
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
|
||||
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
|
||||
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
|
||||
]
|
||||
|
||||
export default function TeamForumSettings() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const [state, setState] = useState(null)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [mode, setMode] = useState('disabled')
|
||||
const [editWindow, setEditWindow] = useState('15')
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.admin.teamForumSettings()
|
||||
setState(s)
|
||||
setEnabled(s.enabled)
|
||||
setMode(s.imageMode)
|
||||
setEditWindow(String(s.editWindowMinutes ?? 15))
|
||||
} catch {
|
||||
setError('Could not load forum settings.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
if (!state) return null
|
||||
|
||||
const stale = state.acknowledgement?.stale
|
||||
|
||||
async function persist(next, acknowledge) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updateSettings({
|
||||
teams_forums_enabled: next.enabled ? '1' : '0',
|
||||
teams_forum_images: next.mode,
|
||||
teams_forum_edit_window_minutes: String(next.editWindow),
|
||||
...(acknowledge ? { acknowledge } : {}),
|
||||
})
|
||||
setSaved(true)
|
||||
await load()
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save forum settings.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Moving TO uploads asks first; every other change saves directly. A stale
|
||||
// acknowledgement also routes through the dialog, because re-acknowledging is
|
||||
// the only thing that unfreezes these settings.
|
||||
function save() {
|
||||
setSaved(false)
|
||||
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
if (stale) {
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
persist({ enabled, mode, editWindow })
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 620 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
|
||||
|
||||
{stale && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
|
||||
The image-upload notice has changed since it was accepted
|
||||
{state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
|
||||
Uploads keep working, but no forum setting can be saved until it is acknowledged again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
Off by default. Switching forums off hides them completely — every forum route answers “not
|
||||
found” — but deletes nothing: threads, posts, access grants and notification preferences all
|
||||
survive and come back exactly as they were.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Images in forum posts</span>
|
||||
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
|
||||
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Post edit window (minutes)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={0}
|
||||
max={state.editWindowMax ?? 1440}
|
||||
value={editWindow}
|
||||
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
How long an author may edit their own post after writing it. Staff are not bound by it and
|
||||
may edit at any time. Set it to 0 to make posts permanent once written — a bound of some
|
||||
kind is what stops a post being rewritten out from under someone quoting it, or under a
|
||||
moderator about to act on a report.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
|
||||
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||
<ul style={{ margin: '0 0 6px 18px' }}>
|
||||
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
|
||||
</ul>
|
||||
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||
{mode !== 'disabled' && (
|
||||
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save forum settings'}
|
||||
</button>
|
||||
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
|
||||
{dialog && (
|
||||
<UploadsDialog
|
||||
version={state.acknowledgement.version}
|
||||
onCancel={() => {
|
||||
setDialog(null)
|
||||
setMode(state.imageMode)
|
||||
setEnabled(state.enabled)
|
||||
setEditWindow(String(state.editWindowMinutes ?? 15))
|
||||
}}
|
||||
onConfirm={async (version) => {
|
||||
setDialog(null)
|
||||
await persist(dialog, version)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two checkboxes, one recorded acknowledgement.
|
||||
*
|
||||
* `Enable uploads` stays disabled until both are ticked, but the request carries a
|
||||
* single version and the stored value is the text VERSION. Recording two booleans
|
||||
* would add nothing — there is no reachable state where an operator consented to
|
||||
* one clause and not the other and proceeded anyway — while the version answers
|
||||
* the question that actually matters later: which text did they agree to?
|
||||
*/
|
||||
function UploadsDialog({ version, onCancel, onConfirm }) {
|
||||
const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
|
||||
const all = checks.every(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enable image uploads"
|
||||
style={{
|
||||
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
|
||||
⚠ Image uploads are currently disabled.
|
||||
</p>
|
||||
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
|
||||
Enabling uploads will allow users to store files on your server.
|
||||
</p>
|
||||
{DIALOG_CHECKS.map((text, i) => (
|
||||
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checks[i]}
|
||||
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{text}
|
||||
</label>
|
||||
))}
|
||||
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={!all}
|
||||
onClick={() => onConfirm(version)}
|
||||
>
|
||||
Enable uploads
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
281
client/src/routes/admin/views/TeamIntegrations.jsx
Normal file
281
client/src/routes/admin/views/TeamIntegrations.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
||||
setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
||||
} from '../../../lib/teamIntegrations.js'
|
||||
|
||||
// The Team notification bridge (TEAMS.md §7.2, phase 8).
|
||||
//
|
||||
// Named for the TEAM concern rather than for Discord, and placed under Teams
|
||||
// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
|
||||
// with whatever the capability registry declares. What changes then should be
|
||||
// what fills this panel, not where an operator goes to find it. Nothing below
|
||||
// hardcodes the word except the heading the server sends as `platform`.
|
||||
//
|
||||
// **The checkbox in the dialog is not the gate.** The server refuses to enable a
|
||||
// row carrying `team.forum.post` or `team.announcement` without the
|
||||
// acknowledgement, 422, whether or not this dialog was ever rendered — the same
|
||||
// division TeamForumSettings draws for image uploads. What is here is how the
|
||||
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
|
||||
// agreeing is a deliberate act rather than a checkbox they tab past.
|
||||
|
||||
const ACK_TEXT = [
|
||||
'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can'
|
||||
+ ' read a channel on another platform, so it cannot check that for you.',
|
||||
'By enabling these events you confirm that the destination channel is restricted to the members of'
|
||||
+ ' the Team whose posts it will carry.',
|
||||
]
|
||||
|
||||
export default function TeamIntegrations() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [teams, setTeams] = useState([])
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
|
||||
setConfig(cfg)
|
||||
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
|
||||
} catch (err) {
|
||||
// A moderator never reaches this panel — the admin nav does not render it —
|
||||
// so a 403 here means the role changed underneath an open tab rather than a
|
||||
// routing mistake, and saying so beats "could not load".
|
||||
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const membersOnlyIds = membersOnlyIdsOf(config.events)
|
||||
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
|
||||
|
||||
async function persist(next) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
await api.admin.saveTeamIntegration({
|
||||
teamId: next.teamId,
|
||||
events: next.events,
|
||||
channelRef: next.channelRef.trim() || null,
|
||||
enabled: next.enabled,
|
||||
membersAck: next.membersAck,
|
||||
})
|
||||
setDraft(null)
|
||||
setDialog(null)
|
||||
setNotice('Saved.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
setDialog(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enabling members-only events without a standing acknowledgement asks first.
|
||||
// Everything else — disabling, editing a channel, adding a roster event — saves
|
||||
// straight through.
|
||||
function save() {
|
||||
if (!draft) return
|
||||
if (needsAcknowledgement(draft, membersOnlyIds)) {
|
||||
setDialog(draft)
|
||||
return
|
||||
}
|
||||
persist(draft)
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.deleteTeamIntegration(row.team_id ?? null)
|
||||
setNotice('Removed.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
|
||||
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
|
||||
override it for individual Teams. A message is sent once and not retried — the bridge is a
|
||||
courtesy, and nothing on the site depends on it arriving.
|
||||
</p>
|
||||
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
|
||||
{config.rows.length === 0 && !draft && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured — no Team events leave the site.</p>
|
||||
)}
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Applies to</th><th>Events</th><th>Channel</th><th>State</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={rowKey(row)}>
|
||||
<td>
|
||||
{appliesToLabel(row)}
|
||||
{isDefaultRow(row) && <span className="dim"> (default)</span>}
|
||||
</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>
|
||||
{row.events.length === 0
|
||||
? <span className="dim">none</span>
|
||||
: row.events.map(eventLabel).join(', ')}
|
||||
</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.channel_ref || <span className="dim">unset</span>}</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>
|
||||
{row.enabled ? 'Enabled' : 'Disabled'}
|
||||
{row.members_ack && (
|
||||
<span className="dim" style={{ display: 'block' }}>
|
||||
members-only destination confirmed
|
||||
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!draft && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{!hasDefault && (
|
||||
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(null))}>
|
||||
Set a default for all Teams
|
||||
</button>
|
||||
)}
|
||||
{available.length > 0 && (
|
||||
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(available[0].id))}>
|
||||
Add a per-Team override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft && (
|
||||
<div style={{ marginTop: 18, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Applies to</span>
|
||||
<select
|
||||
className="select"
|
||||
value={draft.teamId === null ? 'default' : String(draft.teamId)}
|
||||
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
|
||||
>
|
||||
<option value="default">All Teams (default)</option>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="field-label">Events to send</span>
|
||||
{config.events.map((event) => (
|
||||
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.events.includes(event.id)}
|
||||
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
|
||||
{event.membersOnly && (
|
||||
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Channel id</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.channelRef}
|
||||
// Changing the channel drops a standing acknowledgement in the SAME
|
||||
// place the server does. Leaving the tick showing while the server
|
||||
// has already decided to clear it would let an operator repoint a row
|
||||
// at a public channel and believe the confirmation still covered it.
|
||||
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
|
||||
placeholder="1024839201048392010"
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
|
||||
the new channel’s audience again.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
|
||||
</label>
|
||||
|
||||
{draft.membersAck && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
|
||||
You have confirmed this channel is restricted to the Team’s members.{' '}
|
||||
<button type="button" className="btn-ghost" onClick={() => setDraft({ ...draft, membersAck: false })}>
|
||||
Withdraw
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<div style={{ marginTop: 18, border: '1px solid #e0b877', padding: 14, borderRadius: 4 }}>
|
||||
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destination’s audience</h3>
|
||||
{ACK_TEXT.map((line) => (
|
||||
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy}
|
||||
onClick={() => persist({ ...dialog, membersAck: true })}
|
||||
>
|
||||
I confirm the channel is members-only
|
||||
</button>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
256
client/src/routes/admin/views/TeamVoice.jsx
Normal file
256
client/src/routes/admin/views/TeamVoice.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
|
||||
parseStaffRoles, formatStaffRoles, statusSummary,
|
||||
} from '../../../lib/teamVoice.js'
|
||||
|
||||
// Team voice channels (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// Named for the Team concern and placed under Teams beside the notification
|
||||
// bridge, for the reason that panel gives: phase 10 replaces "Discord" with
|
||||
// whatever the capability registry declares, and what should change then is what
|
||||
// fills this panel rather than where an operator goes to find it.
|
||||
//
|
||||
// **The preflight is the first thing on the page, not a diagnostic.** §7.3
|
||||
// assumed the bot could manage channels and roles; nothing in this project has
|
||||
// ever checked, because the operator invites the bot by hand and no invite URL
|
||||
// with a permission integer exists anywhere in the tree. An operator whose bot
|
||||
// lacks Manage Roles otherwise has a screen full of controls that cannot work,
|
||||
// and finds out one Team at a time from a column of identical errors.
|
||||
|
||||
export default function TeamVoice() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const cfg = await api.admin.teamVoice()
|
||||
setConfig(cfg)
|
||||
setDraft({
|
||||
enabled: cfg.settings.enabled,
|
||||
minMembers: cfg.settings.minMembers,
|
||||
graceDays: cfg.settings.graceDays,
|
||||
staffRoles: formatStaffRoles(cfg.settings.staffRoles),
|
||||
})
|
||||
} catch (err) {
|
||||
// A moderator never reaches this panel — the admin nav does not render it —
|
||||
// so a 403 means the role changed underneath an open tab.
|
||||
setError(err.status === 403
|
||||
? 'Only an admin can configure Team voice channels.'
|
||||
: (err.message || 'Could not load the voice configuration.'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!config || !draft) {
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const blocked = enableBlockedReason(config.preflight)
|
||||
const headroom = roleHeadroom(config.preflight)
|
||||
|
||||
async function save() {
|
||||
const { roles, invalid } = parseStaffRoles(draft.staffRoles)
|
||||
if (invalid.length > 0) {
|
||||
setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
await api.admin.saveTeamVoice({
|
||||
enabled: draft.enabled,
|
||||
minMembers: Number(draft.minMembers),
|
||||
graceDays: Number(draft.graceDays),
|
||||
staffRoles: roles,
|
||||
})
|
||||
setNotice('Saved.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPass() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const result = await api.admin.teamVoicePass()
|
||||
// A pass that refused says why, and that is the useful answer far more often
|
||||
// than a count is — "stale projection" and "synced 0" look identical in a
|
||||
// summary and mean completely different things.
|
||||
setNotice(result.ran
|
||||
? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.`
|
||||
: `Nothing was done: ${result.reason}`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not run a pass.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.removeTeamVoice(row.teamId)
|
||||
setNotice('Removed.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 760 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Voice channels</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
|
||||
Give each Team a {config.platform} voice channel of its own. Access is granted with a role per
|
||||
Team, so members of a Team can see and join their channel and nobody else can. Members need a
|
||||
linked {config.platform} account and must be in the guild.
|
||||
</p>
|
||||
|
||||
{blocked && (
|
||||
<p className="sans" style={{ color: '#e0b877', fontSize: '0.82rem' }}>
|
||||
{blocked} Voice channels cannot be switched on until that is fixed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{headroom && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{headroom.used} of {headroom.cap} {config.platform} roles used in this guild
|
||||
{headroom.exhausted
|
||||
? ' — no room for another Team.'
|
||||
: headroom.tight
|
||||
? ` — room for about ${headroom.free} more Teams.`
|
||||
: '.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
|
||||
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
|
||||
|
||||
<div style={{ marginTop: 14, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
|
||||
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
disabled={busy || (!!blocked && !draft.enabled)}
|
||||
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||
/>
|
||||
{' '}Provision voice channels for Teams
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Minimum members</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={draft.minMembers}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
Every active member counts, whether or not they have linked an account.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Grace window (days)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="90"
|
||||
value={draft.graceDays}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
|
||||
window keeps the same channel; zero removes it on the next pass.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Staff roles</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft.staffRoles}
|
||||
disabled={busy}
|
||||
placeholder="role id, role id"
|
||||
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
Roles that can see and join every Team’s channel. Guild administrators already can, so this
|
||||
is for staff who are not administrators. Leave empty if there are none.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={runPass}>Sync now</button>
|
||||
</div>
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<table className="table" style={{ marginTop: 18 }}>
|
||||
<thead>
|
||||
<tr><th>Team</th><th>Members</th><th>Channel</th><th>State</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={row.teamId}>
|
||||
<td>{row.teamName}</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.memberCount}</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>
|
||||
{row.channelRef || <span className="dim">none</span>}
|
||||
</td>
|
||||
<td className="sans" style={{ fontSize: '0.76rem' }}>
|
||||
{stateLabel(row.state)}
|
||||
{removalCountdown(row) && (
|
||||
<span className="dim" style={{ display: 'block' }}>{removalCountdown(row)}</span>
|
||||
)}
|
||||
{row.lastError && (
|
||||
<span style={{ display: 'block', color: '#e08b77' }}>{row.lastError}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{config.lastPass && config.lastPass.at && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
|
||||
Last pass {new Date(config.lastPass.at).toLocaleString()}
|
||||
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
392
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
392
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import {
|
||||
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
|
||||
} from '../../../lib/teamAdmin.js'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import TeamIntegrations from './TeamIntegrations.jsx'
|
||||
import TeamVoice from './TeamVoice.jsx'
|
||||
|
||||
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
|
||||
//
|
||||
// Three panels, in the order an operator needs them:
|
||||
//
|
||||
// 1. **Sync state**, verbatim, including the last error. The screen's first job
|
||||
// is to make "the shard has no Teams" and "core has not been able to ask for
|
||||
// two hours" impossible to confuse — they render almost identically
|
||||
// otherwise, and one is fine while the other is an outage.
|
||||
// 2. **The review queue** — Teams auto-hidden because their name matched the
|
||||
// impersonation list, each showing which term matched.
|
||||
// 3. **The approval queue** — what moderators have asked to publish.
|
||||
//
|
||||
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
|
||||
// plain JS and has tests; this file renders it.
|
||||
|
||||
const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' }
|
||||
|
||||
function Pill({ tone, children }) {
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sync state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SyncPanel({ sync, syncState, onResync, busy }) {
|
||||
const freshness = freshnessOf(sync)
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}>
|
||||
<h2 style={{ margin: 0 }}>Sync</h2>
|
||||
<Pill tone={freshness.tone}>{freshness.label}</Pill>
|
||||
<button type="button" className="btn" onClick={onResync} disabled={busy || !sync.configured}>
|
||||
{busy ? 'Resyncing…' : 'Resync now'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: '.5rem' }}>{freshness.detail}</p>
|
||||
|
||||
{syncState && (
|
||||
<dl className="kv" style={{ marginTop: '.75rem' }}>
|
||||
<dt>Module</dt><dd>{syncState.moduleId}</dd>
|
||||
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
|
||||
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
|
||||
<dt>Consecutive failures</dt><dd>{syncState.consecutiveFailures}</dd>
|
||||
{syncState.lastError && (
|
||||
<>
|
||||
{/* Verbatim. An operator debugging a stale projection needs what the
|
||||
provider actually said, not a friendlier paraphrase of it. */}
|
||||
<dt>Last error</dt>
|
||||
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd>
|
||||
</>
|
||||
)}
|
||||
{syncState.pendingEmptySince && (
|
||||
<>
|
||||
<dt>Empty answer held</dt>
|
||||
<dd>
|
||||
since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is
|
||||
applied only if the next answer agrees.
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The reserved-name review queue ─────────────────────────────────────────
|
||||
|
||||
function ReviewQueue({ rows, role, onAct, busy }) {
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2>Names to review</h2>
|
||||
<p className="muted">
|
||||
These Teams are hidden from every public surface because their name matched a reserved term.
|
||||
They work normally for their own members. {GATED_NOTE}
|
||||
</p>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name}</td>
|
||||
<td><Pill tone="bad">{row.hidden_term}</Pill></td>
|
||||
<td>{row.member_count}</td>
|
||||
<td>{dateTime(row.created_at)}</td>
|
||||
<td>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The approval queue ─────────────────────────────────────────────────────
|
||||
|
||||
function RequestQueue({ rows, role, onDecide, busy }) {
|
||||
if (!rows.length) return null
|
||||
const canDecide = role === 'admin'
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2>Awaiting approval</h2>
|
||||
<p className="muted">
|
||||
{canDecide
|
||||
? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
|
||||
: 'Only an admin can decide these. Your own requests stay here until one does.'}
|
||||
</p>
|
||||
<ul className="list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span>{describeRequest(row)}</span>
|
||||
<span className="muted">{dateTime(row.requested_at)}</span>
|
||||
{row.reason && <span className="muted">“{row.reason}”</span>}
|
||||
{canDecide && (
|
||||
<>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}>
|
||||
Approve
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One Team ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TeamRow({ team, role, onAct, busy, onLedger }) {
|
||||
const status = statusOf(team)
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
{team.displayName}
|
||||
{team.displayNameOverride && (
|
||||
<div className="muted" style={{ fontSize: '.85em' }}>
|
||||
shown instead of “{team.name}”
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td><Pill tone={status.tone}>{status.label}</Pill></td>
|
||||
<td>{team.memberCount}</td>
|
||||
<td>{team.linkedCount}</td>
|
||||
<td>{team.onlineCount}</td>
|
||||
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td>
|
||||
<td>
|
||||
{team.status === 'active' && (team.hidden
|
||||
? (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'unhide')}>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'hide')}>
|
||||
Hide
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
|
||||
Forum log
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One Team's forum moderation ledger (TEAMS.md §5.3).
|
||||
*
|
||||
* The route and the API method have existed since phase 4 and nothing rendered
|
||||
* them, which made the ledger a table only a DB client could read. The column
|
||||
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
|
||||
* so a leader's ordinary housekeeping stays distinguishable from a staff
|
||||
* intervention after the fact.
|
||||
*
|
||||
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
|
||||
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
|
||||
* locking a thread through it would make ordinary housekeeping an appealable
|
||||
* sanction with a reversal path into the bot. Every STAFF-exercised action here
|
||||
* additionally writes activity_log, so the site's accountability trail sees it —
|
||||
* the two are cross-referenced, not merged.
|
||||
*/
|
||||
function ForumLedger({ team, onClose }) {
|
||||
const [rows, setRows] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin.teamForumModeration(team.id)
|
||||
// `{ entries }`, and the rows are the ledger table's own snake_case
|
||||
// columns — this endpoint serves them unmapped, unlike the Team payloads
|
||||
// above it. Reading them as they are, rather than accepting three possible
|
||||
// shapes, is what makes a change to that endpoint fail here instead of
|
||||
// rendering an empty table.
|
||||
.then((res) => { if (active) setRows(res.entries) })
|
||||
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
|
||||
return () => { active = false }
|
||||
}, [team.id])
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h2>Forum log — {team.displayName}</h2>
|
||||
<button type="button" className="btn" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
{error && <ErrorState message={error} />}
|
||||
{!rows && !error && <Loading />}
|
||||
{rows && rows.length === 0 && <p className="muted">Nothing has been moderated in this forum.</p>}
|
||||
{rows && rows.length > 0 && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="muted">{dateTime(r.created_at)}</td>
|
||||
<td>{r.action}</td>
|
||||
<td className="muted">{r.target_type} #{r.target_id}</td>
|
||||
<td>{r.actor_username || '—'}</td>
|
||||
<td>
|
||||
{/* The distinction the whole ledger exists to preserve. */}
|
||||
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
|
||||
</td>
|
||||
<td className="muted">{r.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TeamsAdmin() {
|
||||
const { user } = useAuth()
|
||||
const role = user ? user.role : null
|
||||
|
||||
const [data, setData] = useState(null)
|
||||
const [review, setReview] = useState([])
|
||||
const [requests, setRequests] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [ledgerTeam, setLedgerTeam] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [teams, reviewQueue, requestQueue] = await Promise.all([
|
||||
api.admin.listTeams(),
|
||||
api.admin.teamReviewQueue(),
|
||||
api.admin.teamRequests('pending'),
|
||||
])
|
||||
setData(teams)
|
||||
setReview(reviewQueue.teams || [])
|
||||
setRequests(requestQueue.requests || [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load Teams.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function run(fn, pendingMessage) {
|
||||
setBusy(true)
|
||||
setNotice('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
// The server decides whether an action applied or was filed, from the
|
||||
// caller's live role. Saying so plainly is what stops a moderator thinking
|
||||
// nothing happened.
|
||||
if (result && result.pending) setNotice(pendingMessage)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const act = (id, action) => run(
|
||||
() => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)),
|
||||
'Filed for approval. Nothing has changed publicly until an admin approves it.',
|
||||
)
|
||||
|
||||
const decide = (id, status) => run(
|
||||
() => api.admin.decideTeamRequest(id, status),
|
||||
'',
|
||||
)
|
||||
|
||||
const resync = () => run(async () => {
|
||||
const result = await api.admin.resyncTeams()
|
||||
// A refusal is the normal, designed outcome when the provider cannot answer,
|
||||
// so it is reported as a result rather than thrown as an error.
|
||||
if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`)
|
||||
else if (result.quarantined) {
|
||||
setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.')
|
||||
}
|
||||
return null
|
||||
}, '')
|
||||
|
||||
if (error && !data) return <ErrorState message={error} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Teams</h1>
|
||||
{error && <ErrorState message={error} />}
|
||||
{notice && <p className="notice">{notice}</p>}
|
||||
|
||||
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
|
||||
|
||||
{/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
|
||||
be a panel every action in fails 403 — the role gate is the server's, and
|
||||
this is only how the screen agrees with it. */}
|
||||
{role === 'admin' && <TeamIntegrations />}
|
||||
{role === 'admin' && <TeamVoice />}
|
||||
|
||||
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
||||
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
||||
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
||||
|
||||
<section className="panel">
|
||||
<h2>All Teams</h2>
|
||||
{!data.teams.length && (
|
||||
<p className="muted">
|
||||
{data.configured
|
||||
? 'No Teams in the projection yet.'
|
||||
: 'No installed module supplies Teams, so there is nothing to show.'}
|
||||
</p>
|
||||
)}
|
||||
{data.teams.length > 0 && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th>
|
||||
<th>Roster confirmed</th><th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.teams.map((team) => (
|
||||
<TeamRow
|
||||
key={team.id}
|
||||
team={team}
|
||||
role={role}
|
||||
onAct={act}
|
||||
busy={busy}
|
||||
onLedger={setLedgerTeam}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { leadershipOf }
|
||||
268
client/src/routes/player/PlayerNotifications.jsx
Normal file
268
client/src/routes/player/PlayerNotifications.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
|
||||
//
|
||||
// **This screen did not exist before phase 6, and that was the phase's first
|
||||
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
|
||||
// notification settings screen" — there was no such screen on the web. The stream
|
||||
// catalog and the per-stream subscriptions have been built and shipped since M7,
|
||||
// with the Android app as their only consumer; a browser could not see them at
|
||||
// all. That is tolerable for push, which needs the app anyway. It is not tolerable
|
||||
// for email, whose whole reason for existing (§6.4) is the web-only user who runs
|
||||
// neither the app nor Discord — so the sink and the screen to configure it had to
|
||||
// arrive together.
|
||||
//
|
||||
// Three blocks, in the order a user actually reasons about them: what kinds of
|
||||
// thing to be told about, then which Teams, then whether any of it should reach a
|
||||
// mailbox.
|
||||
|
||||
const EMAIL_MODES = [
|
||||
{ value: 'off', label: 'No email' },
|
||||
{ value: 'digest', label: 'Daily digest' },
|
||||
{ value: 'immediate', label: 'Every post' },
|
||||
]
|
||||
|
||||
// Streams whose scoping lives in this page's second block rather than in the
|
||||
// first. Shown as a group so a user does not toggle `team.forum.post` off site-
|
||||
// wide when what they meant was "not this one guild".
|
||||
const isTeamStream = (id) => String(id).startsWith('team.')
|
||||
|
||||
function Section({ title, hint, children }) {
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Note({ msg, error }) {
|
||||
if (!msg && !error) return null
|
||||
return (
|
||||
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
|
||||
{error || msg}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ── What to be told about ──────────────────────────────────────────────────
|
||||
|
||||
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
|
||||
const [set, setSet] = useState(() => new Set(subscribed))
|
||||
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
|
||||
|
||||
const toggle = (id) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setSet(next)
|
||||
}
|
||||
|
||||
const team = streams.filter((s) => isTeamStream(s.id))
|
||||
const rest = streams.filter((s) => !isTeamStream(s.id))
|
||||
|
||||
const row = (s) => (
|
||||
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
|
||||
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
|
||||
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="What to notify me about"
|
||||
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
|
||||
{team.length > 0 && (
|
||||
<>
|
||||
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
|
||||
Teams
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Which Teams, and whether by email ──────────────────────────────────────
|
||||
|
||||
function Teams({ teams, onSave, busy, msg, error }) {
|
||||
const [rows, setRows] = useState(teams)
|
||||
useEffect(() => { setRows(teams) }, [teams])
|
||||
|
||||
const patch = (teamId, change) =>
|
||||
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Section title="Teams">
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
|
||||
You are not in a team, and nobody has given you access to a team forum. There is nothing to
|
||||
configure here yet.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Teams"
|
||||
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Team</th>
|
||||
<th style={{ padding: '8px 10px' }}>Notifications</th>
|
||||
<th style={{ padding: '8px 10px' }}>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
|
||||
{t.name}
|
||||
{/* An archived Team is still listed when a preference exists for
|
||||
it, so a mute does not silently vanish when a guild disbands
|
||||
and reappear if it re-forms under the same name. */}
|
||||
{t.archived && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
|
||||
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={t.emailMode}
|
||||
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
|
||||
style={{ fontSize: '0.88rem' }}
|
||||
>
|
||||
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlayerNotifications() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [streams, setStreams] = useState([])
|
||||
const [subscribed, setSubscribed] = useState([])
|
||||
const [teams, setTeams] = useState([])
|
||||
const [saving, setSaving] = useState({ streams: false, teams: false })
|
||||
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
|
||||
// the Team list are this user's. None depends on another.
|
||||
const [cat, subs, prefs] = await Promise.all([
|
||||
api.notificationStreams(),
|
||||
api.notificationSubscriptions(),
|
||||
api.teamNotificationPrefs(),
|
||||
])
|
||||
setStreams(cat.streams || [])
|
||||
setSubscribed(subs.streams || [])
|
||||
setTeams(prefs.teams || [])
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Could not load your notification settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const saveStreams = useCallback(async (ids) => {
|
||||
setSaving((s) => ({ ...s, streams: true }))
|
||||
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
|
||||
try {
|
||||
const { streams: stored } = await api.setNotificationSubscriptions(ids)
|
||||
setSubscribed(stored || [])
|
||||
setNotes((n) => ({ ...n, streams: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, streams: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveTeams = useCallback(async (rows) => {
|
||||
setSaving((s) => ({ ...s, teams: true }))
|
||||
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
|
||||
try {
|
||||
// The whole set, every time, and the array is sent even when empty — the
|
||||
// endpoint requires the field (docs/android/PLAN.md §11).
|
||||
const { teams: stored } = await api.setTeamNotificationPrefs(
|
||||
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
|
||||
)
|
||||
setTeams(stored || [])
|
||||
setNotes((n) => ({ ...n, teams: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, teams: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Choose what you are told about, and how. Nothing here is on by default except team
|
||||
notifications to the app, which you can mute per team below.
|
||||
</p>
|
||||
<Streams
|
||||
streams={streams}
|
||||
subscribed={subscribed}
|
||||
onSave={saveStreams}
|
||||
busy={saving.streams}
|
||||
msg={notes.streams}
|
||||
error={notes.streamsError}
|
||||
/>
|
||||
<Teams
|
||||
teams={teams}
|
||||
onSave={saveTeams}
|
||||
busy={saving.teams}
|
||||
msg={notes.teams}
|
||||
error={notes.teamsError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) {
|
||||
}
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||
@@ -47,6 +48,7 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
@@ -56,6 +58,7 @@ export const NAV = [
|
||||
const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/account/notifications': 'Notifications',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
|
||||
69
client/src/routes/player/Unsubscribe.jsx
Normal file
69
client/src/routes/player/Unsubscribe.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The landing page for the unsubscribe link in a Team notification email
|
||||
// (TEAMS.md §6.4).
|
||||
//
|
||||
// **Public, and it must be**: the person reading it is in their mail client, not
|
||||
// signed in, and an unsubscribe that first demands a login is one most people do
|
||||
// not complete. The token in the path is what stands in for the session.
|
||||
//
|
||||
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
|
||||
// mail clients and security scanners follow links in messages, and one that did
|
||||
// would silently mute Teams nobody asked to leave. So the link lands here, this
|
||||
// runs one POST, and the API route that shares the path answers GET with a
|
||||
// redirect to exactly this page.
|
||||
//
|
||||
// **It says the same thing whatever the token was.** A page that distinguished a
|
||||
// valid token from a forged one would be an oracle for which (user, Team) pairs
|
||||
// exist, on a surface with no session behind it. The server always answers 200 and
|
||||
// this always says the same sentence.
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const { token } = useParams()
|
||||
const [state, setState] = useState('working')
|
||||
// React 18 StrictMode mounts an effect twice in development. The POST is
|
||||
// idempotent (it sets a boolean), so a second call is harmless — but it is
|
||||
// still a second request for no reason, and the guard keeps the network panel
|
||||
// honest for anyone debugging this page.
|
||||
const fired = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fired.current) return
|
||||
fired.current = true
|
||||
api.unsubscribeTeam(token)
|
||||
.then(() => setState('done'))
|
||||
// A network failure is the ONE case worth distinguishing, because it is the
|
||||
// one where trying again helps. A rejected token is not: the server does not
|
||||
// tell us, deliberately.
|
||||
.catch(() => setState('failed'))
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website" shell="narrow">
|
||||
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
|
||||
{state === 'working' && <p className="sans dim">One moment…</p>}
|
||||
{state === 'done' && (
|
||||
<>
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
You will not receive further notification emails about this team.
|
||||
</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
|
||||
This muted the team rather than switching off your account’s email, so your other
|
||||
teams are unaffected. You can turn it back on any time under{' '}
|
||||
<Link to="/account/notifications">notification settings</Link>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === 'failed' && (
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
We could not reach the site to record that. Please try the link again, or change the
|
||||
setting yourself under <Link to="/account/notifications">notification settings</Link>.
|
||||
</p>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -298,6 +298,18 @@ button[disabled] {
|
||||
}
|
||||
|
||||
/* ===== Rich prose (wiki / newsletter body) ===== */
|
||||
.forum-embed {
|
||||
/* The image a Team-forum post's URL renders as, in `remote`/`uploads` mode.
|
||||
Emitted by the server (utils/forumHtml.js), never by an author — which is
|
||||
what makes the operator's image policy enforceable. Block, so it sits
|
||||
beneath its link rather than beside it; capped, because a remote image is
|
||||
whatever size its host decided and one post must not blow out the column. */
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
.prose {
|
||||
color: var(--text);
|
||||
font-size: 1.06rem;
|
||||
|
||||
@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
|
||||
await api.admin.disableModule('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||
})
|
||||
|
||||
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
|
||||
//
|
||||
// The URL shapes matter more here than they look. Replies hang off a THREAD;
|
||||
// edits and post moderation hang off a POST; and the report route hangs off the
|
||||
// forum rather than off either, because a report can name a thread, a post or an
|
||||
// upload and is not moderation of any of them.
|
||||
|
||||
test('a reply hangs off its thread and an edit hangs off its post', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReply('ossuary', 5, { body: 'hi' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
|
||||
// PATCH, not POST: an edit replaces part of a post that already exists, and the
|
||||
// server's route is mounted on the verb.
|
||||
assert.equal(calls[0].opts.method, 'PATCH')
|
||||
})
|
||||
|
||||
test('post moderation is a different route from thread moderation', async () => {
|
||||
// Not the same route with a target kind, because the two answer to different
|
||||
// rules — `pin` and `lock` mean nothing to a post at all.
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
|
||||
})
|
||||
|
||||
test('a report goes to the forum, and its queue is under admin moderation', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), {
|
||||
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
|
||||
})
|
||||
|
||||
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
|
||||
// should have one place to work, and there is deliberately no leader-facing
|
||||
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
|
||||
calls = []
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports({ status: 'open' })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
|
||||
})
|
||||
|
||||
test('the report queue defaults to the open work rather than to everything', async () => {
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports()
|
||||
// No query string at all — the server's default is open + reviewing, and a
|
||||
// client that pinned `status=all` here would put the archive in front of a
|
||||
// staffer every time they opened the screen.
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
||||
})
|
||||
|
||||
test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
@@ -160,6 +160,9 @@ test('the registry object handed to modules exposes the whole surface', () => {
|
||||
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||
// member missing from the object is a member that does not exist.
|
||||
assert.deepEqual(Object.keys(registry).sort(), [
|
||||
// `declareModuleSlot` is the INVERTED direction added in 1.6.0: the module
|
||||
// declares a place on its own page and core fills it (TEAMS.md Part 3).
|
||||
'declareModuleSlot',
|
||||
'featureProviderFor',
|
||||
'navFor',
|
||||
'registerExtension',
|
||||
|
||||
@@ -4,6 +4,10 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
registry,
|
||||
declareSlot,
|
||||
declareModuleSlot,
|
||||
offerCoreFill,
|
||||
CORE_CONTRIBUTIONS,
|
||||
applyCoreFills,
|
||||
registerExtension,
|
||||
extensionFor,
|
||||
registeredIds,
|
||||
@@ -92,3 +96,114 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
|
||||
assert.equal(registry.extensionFor, undefined)
|
||||
assert.equal(typeof registry.registerExtension, 'function')
|
||||
})
|
||||
|
||||
// ── The INVERTED direction: the module declares, core fills ────────────────
|
||||
//
|
||||
// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
|
||||
// core surface — core owns the tables and the activity feed, the module owns the
|
||||
// page and the word "guild" — so the content flows the other way for the first
|
||||
// time. The rules below are the ones that direction gets wrong.
|
||||
|
||||
const Feed = () => null
|
||||
|
||||
test('a module-declared slot must be namespaced under the declaring module', () => {
|
||||
// Enforced rather than conventional: this is the only thing keeping two
|
||||
// modules from claiming the same slot name.
|
||||
assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
|
||||
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
|
||||
})
|
||||
|
||||
test('core offers a contribution and the module says where it goes', () => {
|
||||
// The ordering that makes this two calls: core's bundle evaluates BEFORE any
|
||||
// module chunk, so at the moment core offers, no module-declared slot exists.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied')
|
||||
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('core names no slot, so a second game gets the same content in its own words', () => {
|
||||
// The defect this replaced: core used to fill three literal `uo.guild.*` names,
|
||||
// which reached exactly one module. Every other game declared a place under its
|
||||
// own id and got an empty page with no error, because a fill nobody declared is
|
||||
// deliberately not an error — the rule that makes an unknown name invisible.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('examplegame.clan.detail'), Feed)
|
||||
})
|
||||
|
||||
test('two modules can ask for the same contribution, and both get it', () => {
|
||||
// Core has no reason to care how many places want its feed, and refusing the
|
||||
// second would be core making a layout decision on a page it does not own.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
assert.equal(extensionFor('uo.guild.summary'), Feed)
|
||||
})
|
||||
|
||||
test('a slot that asks for nothing stays empty', () => {
|
||||
// Optional on purpose: a module may declare a place it fills itself, or one it
|
||||
// is keeping for later. Neither is core's business.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
test('asking for a contribution core does not offer THROWS', () => {
|
||||
// The asymmetry with an unfilled slot, and it is deliberate. An unknown
|
||||
// contribution is always a typo or a version skew — core's list is fixed at
|
||||
// build time and the module's coreApi range has already been checked — and the
|
||||
// alternative failure is a page that renders empty forever with nothing logged.
|
||||
assert.throws(
|
||||
() => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }),
|
||||
/does not offer/,
|
||||
)
|
||||
assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it')
|
||||
})
|
||||
|
||||
test('a contribution nothing asks for is not an error', () => {
|
||||
// No game module installed. Core offering content for a page that does not
|
||||
// exist is the ordinary case on any deployment, not a misconfiguration.
|
||||
offerCoreFill('team.forum', Feed)
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
})
|
||||
|
||||
test('a module that fills its own slot first keeps it', () => {
|
||||
const Own = () => null
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
registerExtension('uo', 'uo.guild.detail', Own)
|
||||
offerCoreFill('team.activity', Feed)
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
|
||||
})
|
||||
|
||||
test('a module-declared slot cannot be declared twice', () => {
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/)
|
||||
})
|
||||
|
||||
test('applying the fills twice does not re-fill or throw', () => {
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
offerCoreFill('team.activity', Feed)
|
||||
applyCoreFills()
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('a non-component contribution is refused at the call site, not at render', () => {
|
||||
assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/)
|
||||
})
|
||||
|
||||
test('_reset clears pending fills, so one test cannot leak into the next', () => {
|
||||
offerCoreFill('team.activity', Feed)
|
||||
_reset()
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
78
client/test/teamActivity.test.js
Normal file
78
client/test/teamActivity.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// What core's Team activity feed says (client/src/lib/teamActivity.js).
|
||||
//
|
||||
// The test that earns this file: a projection nobody can tell is stale, and a
|
||||
// feed nobody can tell is filtered, both look like complete information. Every
|
||||
// case below is about saying which one the reader is looking at.
|
||||
//
|
||||
// Note the wording assertions avoid core's own noun. The feed renders inside a
|
||||
// page a MODULE titled — Guilds today, Clans next — so "this Team" would be
|
||||
// core's vocabulary leaking onto a surface that deliberately does not use it.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { activityScopeNote, freshnessNote, groupByDay, relativeTime } from '../src/lib/teamActivity.js'
|
||||
|
||||
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
|
||||
const ago = (ms) => new Date(NOW - ms).toISOString()
|
||||
|
||||
test('a deployment with no provider is not stale, it is uninvolved', () => {
|
||||
assert.equal(freshnessNote({ configured: false }, NOW), null)
|
||||
})
|
||||
|
||||
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.match(note.text, /Not yet confirmed/)
|
||||
})
|
||||
|
||||
test('a stale projection says how old it is and that the game may have moved on', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
|
||||
})
|
||||
|
||||
test('a current projection is stated quietly', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
|
||||
assert.equal(note.tone, 'idle')
|
||||
assert.equal(note.text, 'Last confirmed 1 minute ago.')
|
||||
})
|
||||
|
||||
test('relative time singularises and steps through the units', () => {
|
||||
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
|
||||
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
|
||||
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
|
||||
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
|
||||
assert.equal(relativeTime(null, NOW), null)
|
||||
assert.equal(relativeTime('not a date', NOW), null)
|
||||
})
|
||||
|
||||
test('items group into days, newest day first, order kept within a day', () => {
|
||||
const days = groupByDay([
|
||||
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
|
||||
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
|
||||
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
|
||||
], 'en-US')
|
||||
assert.equal(days.length, 2)
|
||||
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
|
||||
assert.deepEqual(days[1].items.map((i) => i.id), [1])
|
||||
})
|
||||
|
||||
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
|
||||
assert.deepEqual(groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US'), [])
|
||||
})
|
||||
|
||||
test('a caller who saw everything is told nothing', () => {
|
||||
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
|
||||
})
|
||||
|
||||
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
|
||||
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
|
||||
assert.match(activityScopeNote({ scope: 'public' }, true), /members only/)
|
||||
})
|
||||
|
||||
test('the wording never says "Team" — that is core\'s noun, not the page\'s', () => {
|
||||
for (const signedIn of [true, false]) {
|
||||
assert.doesNotMatch(activityScopeNote({ scope: 'public' }, signedIn), /Team/)
|
||||
}
|
||||
assert.doesNotMatch(freshnessNote({ configured: true, lastSyncAt: null }, NOW).text, /Team/)
|
||||
})
|
||||
140
client/test/teamAdmin.test.js
Normal file
140
client/test/teamAdmin.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// What Admin → Teams says (client/src/lib/teamAdmin.js).
|
||||
//
|
||||
// The test that earns this file: "no Teams" and "core has not been able to ask"
|
||||
// must never read the same. They produce almost identical screens — an empty
|
||||
// table — and one is fine while the other is an outage an operator needs to act
|
||||
// on. Everything else here is in service of that distinction.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
freshnessOf, ago, statusOf, gateLabelFor, describeRequest, parsePayload, leadershipOf, TONE,
|
||||
} from '../src/lib/teamAdmin.js'
|
||||
|
||||
const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString()
|
||||
|
||||
// ── Freshness: four states that must not be confused ───────────────────────
|
||||
|
||||
test('no provider is idle, not a fault', () => {
|
||||
const f = freshnessOf({ configured: false })
|
||||
assert.equal(f.tone, TONE.idle)
|
||||
assert.match(f.label, /No Team provider/)
|
||||
})
|
||||
|
||||
test('never synced is reported as never synced, not as an empty shard', () => {
|
||||
// The failure this prevents: an empty projection core has never confirmed,
|
||||
// rendered as though the game genuinely has no Teams.
|
||||
const f = freshnessOf({ configured: true, lastSyncAt: null })
|
||||
assert.equal(f.tone, TONE.bad)
|
||||
assert.equal(f.label, 'Never synced')
|
||||
assert.match(f.detail, /not a confirmed empty shard/)
|
||||
})
|
||||
|
||||
test('stale says how old it is', () => {
|
||||
const f = freshnessOf({ configured: true, stale: true, lastSyncAt: minutesAgo(14) })
|
||||
assert.equal(f.tone, TONE.warn)
|
||||
assert.equal(f.label, 'Stale')
|
||||
assert.match(f.detail, /14 minutes ago/)
|
||||
})
|
||||
|
||||
test('current says so plainly', () => {
|
||||
const f = freshnessOf({ configured: true, stale: false, lastSyncAt: minutesAgo(2) })
|
||||
assert.equal(f.tone, TONE.ok)
|
||||
assert.equal(f.label, 'Current')
|
||||
})
|
||||
|
||||
test('ago is deliberately coarse', () => {
|
||||
// Second-level precision would be false comfort about a projection whose poll
|
||||
// interval is fifteen minutes.
|
||||
assert.equal(ago(null), 'never')
|
||||
assert.equal(ago(new Date().toISOString()), 'just now')
|
||||
assert.equal(ago(minutesAgo(14)), '14 minutes ago')
|
||||
assert.equal(ago(minutesAgo(60)), '1 hour ago')
|
||||
assert.equal(ago(minutesAgo(180)), '3 hours ago')
|
||||
assert.equal(ago(minutesAgo(60 * 72)), '3 days ago')
|
||||
})
|
||||
|
||||
// ── Status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('the four Team statuses are distinguishable', () => {
|
||||
assert.equal(statusOf({ status: 'active' }).label, 'Public')
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).label, 'Hidden — reserved name')
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).label, 'Hidden by staff')
|
||||
assert.equal(statusOf({ status: 'archived', archivedReason: 'disbanded' }).label, 'Archived')
|
||||
assert.equal(statusOf({ status: 'archived', archivedReason: 'renamed' }).label, 'Renamed')
|
||||
})
|
||||
|
||||
test('a reserved-name hide is the loudest tone', () => {
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).tone, TONE.bad)
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).tone, TONE.warn)
|
||||
})
|
||||
|
||||
// ── The gate, described honestly ───────────────────────────────────────────
|
||||
|
||||
test('the button says what will actually happen for this role', () => {
|
||||
// The server decides from the live role; this only describes it. Saying
|
||||
// "Publish" to a moderator would make the pending result a surprise.
|
||||
assert.equal(gateLabelFor('admin', 'Publish'), 'Publish')
|
||||
assert.equal(gateLabelFor('moderator', 'Publish'), 'Request publish')
|
||||
})
|
||||
|
||||
// ── The approval queue ─────────────────────────────────────────────────────
|
||||
|
||||
test('a request describes itself, including the name being published', () => {
|
||||
assert.equal(
|
||||
describeRequest({ action: 'unhide', requested_username: 'mod1', team_name: 'Admin' }),
|
||||
'mod1 asks to publish “Admin”',
|
||||
)
|
||||
assert.equal(
|
||||
describeRequest({
|
||||
action: 'display_name_override', requested_username: 'mod1', team_name: 'Admin',
|
||||
payload: { displayName: 'The Old Guard' },
|
||||
}),
|
||||
'mod1 asks to display “Admin” as “The Old Guard”',
|
||||
)
|
||||
assert.equal(
|
||||
describeRequest({ action: 'clear_display_name_override', requested_username: 'mod1', team_name: 'X' }),
|
||||
'mod1 asks to clear the display name on “X”',
|
||||
)
|
||||
})
|
||||
|
||||
test('a deleted requester still reads as a sentence', () => {
|
||||
// §2.10 sets requested_by to NULL and keeps the username snapshot; when even
|
||||
// that is gone the queue must not render "null asks to publish".
|
||||
assert.match(describeRequest({ action: 'unhide', team_name: 'Admin' }), /^a deleted user asks/)
|
||||
})
|
||||
|
||||
test('a payload arrives parsed or as a string, and both work', () => {
|
||||
assert.deepEqual(parsePayload({ displayName: 'X' }), { displayName: 'X' })
|
||||
assert.deepEqual(parsePayload('{"displayName":"X"}'), { displayName: 'X' })
|
||||
assert.deepEqual(parsePayload(null), {})
|
||||
assert.deepEqual(parsePayload('not json'), {})
|
||||
})
|
||||
|
||||
// ── Leadership shows the decision, not just the answer ─────────────────────
|
||||
|
||||
test('an unoverridden member reads straight from the projection', () => {
|
||||
const l = leadershipOf({ isLeader: true, isLeaderSynced: true })
|
||||
assert.equal(l.isLeader, true)
|
||||
assert.equal(l.overridden, false)
|
||||
assert.equal(l.note, null)
|
||||
})
|
||||
|
||||
test('an override is shown AS an override, with what the game says', () => {
|
||||
// Staff looking at a roster need to see that a decision was made, not a fact
|
||||
// that looks like the game's.
|
||||
const l = leadershipOf({
|
||||
isLeaderSynced: true,
|
||||
leaderOverride: { effect: 'deny', by: 'mod1', reason: 'harassment' },
|
||||
})
|
||||
assert.equal(l.isLeader, false)
|
||||
assert.equal(l.overridden, true)
|
||||
assert.match(l.note, /Denied by mod1 — harassment/)
|
||||
assert.match(l.note, /the game says leader/)
|
||||
})
|
||||
|
||||
test('a grant override says the game disagrees', () => {
|
||||
const l = leadershipOf({ isLeaderSynced: false, leaderOverride: { effect: 'grant', by: 'root' } })
|
||||
assert.equal(l.isLeader, true)
|
||||
assert.match(l.note, /the game says not a leader/)
|
||||
})
|
||||
120
client/test/teamForum.test.js
Normal file
120
client/test/teamForum.test.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// What the Team forum's client half decides for itself (client/src/lib/teamForum.js).
|
||||
//
|
||||
// The point of this file is how LITTLE that is. Who may post, who may moderate,
|
||||
// whether an image renders and whether a post may be edited are all server
|
||||
// answers the panel reads. What is tested here is the three places the client
|
||||
// turns those answers into what a reader sees — and one property that is easy to
|
||||
// break by accident: the edit offer can only ever be withdrawn here, never
|
||||
// granted.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../src/lib/teamForum.js'
|
||||
|
||||
const NOW = new Date('2026-08-18T12:00:00Z').getTime()
|
||||
const inMinutes = (n) => new Date(NOW + n * 60_000).toISOString()
|
||||
|
||||
// ── the edit offer ─────────────────────────────────────────────────────────
|
||||
|
||||
test('the client can withdraw an edit offer and can never create one', () => {
|
||||
// The server said no. Nothing about a deadline changes that — a future
|
||||
// `editableUntil` on a post the server refused must not become an offer, or
|
||||
// the client would be granting a permission.
|
||||
assert.equal(editOfferOpen({ canEdit: false, editableUntil: inMinutes(10) }, NOW), false)
|
||||
assert.equal(editOfferOpen({ canEdit: false, editableUntil: null }, NOW), false)
|
||||
})
|
||||
|
||||
test('a deadline that has passed while the page sat open withdraws the offer', () => {
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW), true)
|
||||
// Same post, fifteen minutes of the reader staring at it later.
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW + 15 * 60_000), false)
|
||||
})
|
||||
|
||||
test('no deadline means no deadline, not no permission', () => {
|
||||
// Staff are not time-bounded, and `editableUntil: null` is how the server says
|
||||
// so. Reading it as "expired" would take the edit control away from exactly the
|
||||
// people whose authority does not expire.
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: null }, NOW), true)
|
||||
})
|
||||
|
||||
test('an unparseable deadline closes the offer rather than opening it', () => {
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: 'not a date' }, NOW), false)
|
||||
assert.equal(editOfferOpen(null, NOW), false)
|
||||
assert.equal(editOfferOpen(undefined, NOW), false)
|
||||
})
|
||||
|
||||
// ── round-tripping a body back into the composer ───────────────────────────
|
||||
|
||||
test('the image core generated is stripped, and the URL that made it survives', () => {
|
||||
// §5.5.3: the author wrote a URL, core emitted the <img> at read time. Handing
|
||||
// the <img> back would let an author edit markup they never wrote — and the
|
||||
// URL is what re-renders it, so nothing is lost by removing it.
|
||||
const rendered = '<p><a href="https://x/a.png" rel="noopener noreferrer">https://x/a.png</a>'
|
||||
+ '<img src="https://x/a.png" class="forum-embed" referrerpolicy="no-referrer" /></p>'
|
||||
const text = stripToText(rendered)
|
||||
assert.ok(!text.includes('<img'))
|
||||
assert.ok(text.includes('https://x/a.png'))
|
||||
})
|
||||
|
||||
test('paragraphs become blank lines and breaks become newlines', () => {
|
||||
assert.equal(stripToText('<p>One</p><p>Two</p>'), 'One\n\nTwo')
|
||||
assert.equal(stripToText('<p>One<br>Two</p>'), 'One\nTwo')
|
||||
// A paragraph carrying attributes is still a paragraph.
|
||||
assert.equal(stripToText('<p>One</p>\n<p class="x">Two</p>'), 'One\n\nTwo')
|
||||
})
|
||||
|
||||
test('entities decode to what the author typed, and only once', () => {
|
||||
assert.equal(stripToText('<p>Tom & Jerry</p>'), 'Tom & Jerry')
|
||||
assert.equal(stripToText('<p>"quoted"</p>'), '"quoted"')
|
||||
|
||||
// The one that bites: an author who typed a literal "<script>" has it stored
|
||||
// escaped. Decoding entities BEFORE stripping tags would turn it into a real
|
||||
// tag that the strip pass then deletes — silently losing text the author wrote
|
||||
// and which was never dangerous.
|
||||
assert.equal(stripToText('<p><script></p>'), '<script>')
|
||||
// And decoding & first would turn "&lt;" into "<" in two steps.
|
||||
assert.equal(stripToText('<p>&lt;</p>'), '<')
|
||||
})
|
||||
|
||||
test('an empty or absent body is an empty string, never a crash', () => {
|
||||
assert.equal(stripToText(''), '')
|
||||
assert.equal(stripToText(null), '')
|
||||
assert.equal(stripToText(undefined), '')
|
||||
assert.equal(stripToText('<p></p>'), '')
|
||||
})
|
||||
|
||||
// ── the thread list line ───────────────────────────────────────────────────
|
||||
|
||||
test('a discussion counts REPLIES, which is one fewer than its posts', () => {
|
||||
// postCount includes the opening post. Showing it raw would tell a reader a
|
||||
// brand-new thread already has one reply.
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 1 }), 'ada')
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 2 }), 'ada · 1 reply')
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 4 }), 'ada · 3 replies')
|
||||
})
|
||||
|
||||
test('an announcement says so and never counts replies, because it takes none', () => {
|
||||
const line = threadSummary({ type: 'announcement', author: 'aldric', postCount: 1 })
|
||||
assert.equal(line, 'Announcement · aldric')
|
||||
assert.ok(!line.includes('repl'))
|
||||
})
|
||||
|
||||
test('hidden is said out loud — it is only shown to whoever can unhide it', () => {
|
||||
assert.equal(
|
||||
threadSummary({ type: 'discussion', author: 'ada', postCount: 1, status: 'hidden' }),
|
||||
'ada · hidden',
|
||||
)
|
||||
})
|
||||
|
||||
// ── the report control ─────────────────────────────────────────────────────
|
||||
|
||||
test('every reason the server accepts is offered, and no others', () => {
|
||||
// The server validates against its own list; a client offering a reason the
|
||||
// server rejects produces a 400 the reporter cannot act on, and one MISSING a
|
||||
// reason quietly funnels those reports into "other".
|
||||
assert.deepEqual(
|
||||
REPORT_REASONS.map(([value]) => value).sort(),
|
||||
['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'],
|
||||
)
|
||||
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
|
||||
})
|
||||
129
client/test/teamIntegrations.test.js
Normal file
129
client/test/teamIntegrations.test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// What Admin → Teams → Notification bridge decides (client/src/lib/teamIntegrations.js).
|
||||
//
|
||||
// The test that earns this file: **repointing a row must not carry its
|
||||
// acknowledgement across.** That is the one way this screen could actively
|
||||
// mislead — an operator confirms a private channel, changes the id to a public
|
||||
// one, and the form still shows the confirmation as standing. The server clears
|
||||
// it either way, so the failure would be a screen that disagrees with the answer
|
||||
// it is about to get, which is worse than one that simply refuses.
|
||||
//
|
||||
// The rest is the boundary of the confirmation dialog: it must open when it
|
||||
// matters and stay shut when it does not, because a dialog that appears on saves
|
||||
// that did not need it is one people learn to click through.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
||||
setChannel, carriesMembersOnly, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
||||
} from '../src/lib/teamIntegrations.js'
|
||||
|
||||
const MEMBERS_ONLY = ['team.forum.post', 'team.announcement']
|
||||
const ROSTER = 'team.member.joined'
|
||||
const FORUM = 'team.forum.post'
|
||||
|
||||
const draft = (over = {}) => ({ ...blankDraft(null), ...over })
|
||||
|
||||
// ── The acknowledgement dies with its channel ──────────────────────────────
|
||||
|
||||
test('changing the channel drops a standing acknowledgement', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
||||
const after = setChannel(before, '222')
|
||||
assert.equal(after.membersAck, false)
|
||||
assert.equal(after.channelRef, '222')
|
||||
})
|
||||
|
||||
test('setting the SAME channel does not clear it — an unrelated re-render is not a repoint', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true })
|
||||
const after = setChannel(before, '111')
|
||||
assert.equal(after.membersAck, true)
|
||||
assert.equal(after, before, 'and the object is returned unchanged, so nothing re-renders')
|
||||
})
|
||||
|
||||
test('a repointed row needs the dialog again, which is the whole point of clearing it', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
||||
assert.equal(needsAcknowledgement(before, MEMBERS_ONLY), false)
|
||||
assert.equal(needsAcknowledgement(setChannel(before, '222'), MEMBERS_ONLY), true)
|
||||
})
|
||||
|
||||
// ── When the dialog opens ──────────────────────────────────────────────────
|
||||
|
||||
test('enabling a forum event without the tick asks first', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), MEMBERS_ONLY), true)
|
||||
})
|
||||
|
||||
test('a DISABLED draft carrying forum events does not ask — nothing is being published yet', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: false }), MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('a roster-only bridge never asks, however it is configured', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [ROSTER], enabled: true }), MEMBERS_ONLY), false)
|
||||
assert.equal(carriesMembersOnly(draft({ events: [ROSTER] }), MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('an acknowledgement already given means no second dialog for an unrelated edit', () => {
|
||||
const d = draft({ events: [FORUM], enabled: true, membersAck: true, channelRef: '111' })
|
||||
const withRoster = toggleEvent(d, ROSTER)
|
||||
assert.equal(needsAcknowledgement(withRoster, MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('the members-only set comes from the server, not from a list held here', () => {
|
||||
// The client must not decide what is members-only: a future stream added
|
||||
// server-side would silently escape a hardcoded client list.
|
||||
assert.deepEqual(
|
||||
membersOnlyIdsOf([{ id: ROSTER, membersOnly: false }, { id: FORUM, membersOnly: true }]),
|
||||
[FORUM],
|
||||
)
|
||||
// Told nothing is members-only, the dialog never opens — the server is the one
|
||||
// that would then refuse, which is the correct division.
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), []), false)
|
||||
})
|
||||
|
||||
// ── Events, rows and targets ───────────────────────────────────────────────
|
||||
|
||||
test('toggling adds then removes, and preserves selection order', () => {
|
||||
let d = draft()
|
||||
d = toggleEvent(d, FORUM)
|
||||
d = toggleEvent(d, ROSTER)
|
||||
assert.deepEqual(d.events, [FORUM, ROSTER])
|
||||
d = toggleEvent(d, FORUM)
|
||||
assert.deepEqual(d.events, [ROSTER])
|
||||
})
|
||||
|
||||
test('the default row is identified by a NULL team, and an undefined one counts too', () => {
|
||||
assert.equal(isDefaultRow({ team_id: null }), true)
|
||||
assert.equal(isDefaultRow({}), true)
|
||||
assert.equal(isDefaultRow({ team_id: 4 }), false)
|
||||
assert.equal(rowKey({ team_id: null }), 'default')
|
||||
assert.equal(rowKey({ team_id: 4 }), '4')
|
||||
})
|
||||
|
||||
test('a row is labelled by the staff override first, then the name, then its id', () => {
|
||||
assert.equal(appliesToLabel({ team_id: null }), 'All Teams')
|
||||
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real', display_name_override: 'Shown' }), 'Shown')
|
||||
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real' }), 'Real')
|
||||
assert.equal(appliesToLabel({ team_id: 4 }), 'Team #4')
|
||||
})
|
||||
|
||||
test('a Team that already has an override is not offered a second one', () => {
|
||||
const rows = [{ team_id: null }, { team_id: 2 }]
|
||||
const teams = [{ id: 1, status: 'active' }, { id: 2, status: 'active' }, { id: 3, status: 'archived' }]
|
||||
const { hasDefault, teams: available } = availableTargets(rows, teams)
|
||||
assert.equal(hasDefault, true)
|
||||
assert.deepEqual(available.map((t) => t.id), [1], 'the taken one and the archived one are both out')
|
||||
})
|
||||
|
||||
test('with no default configured, the default is still offered', () => {
|
||||
const { hasDefault } = availableTargets([{ team_id: 2 }], [])
|
||||
assert.equal(hasDefault, false)
|
||||
})
|
||||
|
||||
test('a row round-trips through the draft without changing what it means', () => {
|
||||
const row = { team_id: 4, events: [FORUM], channel_ref: '111', enabled: 1, members_ack: 1 }
|
||||
assert.deepEqual(draftFrom(row), { teamId: 4, events: [FORUM], channelRef: '111', enabled: true, membersAck: true })
|
||||
})
|
||||
|
||||
test('an unknown event id renders as itself rather than as blank', () => {
|
||||
assert.equal(eventLabel(FORUM), 'New forum post')
|
||||
assert.equal(eventLabel('team.something.new'), 'team.something.new')
|
||||
})
|
||||
87
client/test/teamNotify.test.js
Normal file
87
client/test/teamNotify.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { api } from '../src/api/client.js'
|
||||
|
||||
// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
|
||||
//
|
||||
// There is no DOM in this runner, so what is asserted here is the WIRE — which is
|
||||
// where this feature's client-side mistakes actually live. Two of them have
|
||||
// already been made once in this repo and are recorded rather than re-derived:
|
||||
//
|
||||
// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
|
||||
// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
|
||||
// kotlinx when it equals that default, so "clear the last entry" arrives as a
|
||||
// body with no array at all and 400s. The web client has no such
|
||||
// serialisation quirk, but it shares the endpoint's contract, and a test that
|
||||
// pins the shape here is what keeps the two clients honest about the same
|
||||
// rule.
|
||||
// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
|
||||
// A GET that mutated would be triggered by every mail-client link scanner.
|
||||
|
||||
let calls
|
||||
const realFetch = global.fetch
|
||||
|
||||
function reply(body = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
calls.push({ url, opts })
|
||||
return reply({ teams: [], streams: [], ok: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => { global.fetch = realFetch })
|
||||
|
||||
const body = (i = 0) => JSON.parse(calls[i].opts.body)
|
||||
|
||||
test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
|
||||
await api.teamNotificationPrefs()
|
||||
// Role-agnostic self-service, the same rule that put the Team forum under
|
||||
// /player rather than behind a staff gate: staff are a superset of players and
|
||||
// manage their own notifications like anyone else.
|
||||
assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
|
||||
assert.equal(calls[0].opts.method ?? 'GET', 'GET')
|
||||
})
|
||||
|
||||
test('saving preferences PUTs the whole set under a `teams` key', async () => {
|
||||
await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
|
||||
assert.equal(calls[0].opts.method, 'PUT')
|
||||
assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
|
||||
})
|
||||
|
||||
test('clearing every preference still sends the array, never an absent key', async () => {
|
||||
await api.setTeamNotificationPrefs([])
|
||||
assert.deepEqual(body(), { teams: [] })
|
||||
assert.equal('teams' in body(), true)
|
||||
})
|
||||
|
||||
test('the same rule holds for the stream subscriptions beside them', async () => {
|
||||
await api.setNotificationSubscriptions([])
|
||||
assert.deepEqual(body(), { streams: [] })
|
||||
})
|
||||
|
||||
test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
|
||||
await api.unsubscribeTeam('1.7.3.abcDEF')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
|
||||
})
|
||||
|
||||
test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
|
||||
await api.unsubscribeTeam('a/b c')
|
||||
assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
|
||||
})
|
||||
|
||||
test('the streams catalog and subscriptions are separate reads', async () => {
|
||||
await api.notificationStreams()
|
||||
await api.notificationSubscriptions()
|
||||
assert.match(calls[0].url, /\/notifications\/streams$/)
|
||||
assert.match(calls[1].url, /\/notifications\/subscriptions$/)
|
||||
})
|
||||
152
client/test/teamVoice.test.js
Normal file
152
client/test/teamVoice.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// Admin → Teams → Voice channels, the decisions (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// These mirror server rules and do not replace them: the server refuses to enable
|
||||
// voice while the bot cannot act, and the reconciler applies the threshold and the
|
||||
// grace window, whether or not this file ever ran. What is asserted here is that
|
||||
// the SCREEN agrees with those answers instead of offering a control that will
|
||||
// fail, or describing a state the deployment is not in.
|
||||
//
|
||||
// The one that matters most is `statusSummary`'s "off" branch. Switching voice off
|
||||
// suspends the reconciler in both directions and deliberately leaves existing
|
||||
// channels standing — a checkbox must not delete structure in somebody's guild —
|
||||
// and an operator who reads "off" as "nothing is provisioned" would never go
|
||||
// looking for the channels that are still there.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
|
||||
parseStaffRoles, formatStaffRoles, statusSummary,
|
||||
} from '../src/lib/teamVoice.js'
|
||||
|
||||
test('every state the server can report has wording', () => {
|
||||
for (const state of ['none', 'active', 'pending_removal', 'error']) {
|
||||
assert.notEqual(stateLabel(state), state)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown state falls back to itself rather than rendering blank', () => {
|
||||
assert.equal(stateLabel('something-new'), 'something-new')
|
||||
})
|
||||
|
||||
// ── The enable gate ────────────────────────────────────────────────────────
|
||||
|
||||
test('a ready bot blocks nothing', () => {
|
||||
assert.equal(enableBlockedReason({ ready: true, connected: true, missingPermissions: [] }), null)
|
||||
})
|
||||
|
||||
test('a disconnected bot and a bot missing a permission read differently', () => {
|
||||
const disconnected = enableBlockedReason({ ready: false, connected: false, reason: 'the bot is not connected to Discord' })
|
||||
const missing = enableBlockedReason({ ready: false, connected: true, missingPermissions: ['Manage Roles'] })
|
||||
assert.match(disconnected, /not connected/)
|
||||
assert.match(missing, /Manage Roles/)
|
||||
// An operator fixes these in two completely different places, so collapsing
|
||||
// them into one message would send half of them to the wrong one.
|
||||
assert.notEqual(disconnected, missing)
|
||||
})
|
||||
|
||||
test('an absent preflight blocks rather than silently allowing', () => {
|
||||
assert.ok(enableBlockedReason(null))
|
||||
assert.ok(enableBlockedReason(undefined))
|
||||
})
|
||||
|
||||
// ── The role ceiling ───────────────────────────────────────────────────────
|
||||
|
||||
test('headroom is counted against the guild-wide cap', () => {
|
||||
const h = roleHeadroom({ roleCount: 200, roleCap: 250 })
|
||||
assert.equal(h.free, 50)
|
||||
assert.equal(h.tight, false)
|
||||
assert.equal(h.exhausted, false)
|
||||
})
|
||||
|
||||
test('a nearly full guild is flagged before the create fails, not after', () => {
|
||||
// The whole reason this is in the panel: access is a per-Team role, so the cap
|
||||
// limits how many TEAMS can have voice, and an operator with sixty guilds needs
|
||||
// to know that before the sixtieth silently errors.
|
||||
const h = roleHeadroom({ roleCount: 240, roleCap: 250 })
|
||||
assert.equal(h.tight, true)
|
||||
assert.equal(h.exhausted, false)
|
||||
})
|
||||
|
||||
test('a full guild is exhausted, and never reports negative headroom', () => {
|
||||
const h = roleHeadroom({ roleCount: 260, roleCap: 250 })
|
||||
assert.equal(h.free, 0)
|
||||
assert.equal(h.exhausted, true)
|
||||
})
|
||||
|
||||
test('no preflight means no claim about headroom', () => {
|
||||
assert.equal(roleHeadroom(null), null)
|
||||
assert.equal(roleHeadroom({}), null)
|
||||
})
|
||||
|
||||
// ── The grace window ───────────────────────────────────────────────────────
|
||||
|
||||
test('a row that is not scheduled has no countdown', () => {
|
||||
assert.equal(removalCountdown({ state: 'active', removeAfter: null }), null)
|
||||
})
|
||||
|
||||
test('a running window reads in days', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-24T00:00:00Z' }, now)
|
||||
assert.equal(text, 'in 5 days')
|
||||
})
|
||||
|
||||
test('under a day reads in hours rather than rounding to zero days', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-19T06:00:00Z' }, now)
|
||||
assert.equal(text, 'in 6 hours')
|
||||
})
|
||||
|
||||
test('an expired window says the next pass will act, not "in 0 days"', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-18T00:00:00Z' }, now)
|
||||
assert.match(text, /next pass/)
|
||||
})
|
||||
|
||||
// ── Staff roles ────────────────────────────────────────────────────────────
|
||||
|
||||
test('staff roles parse from the comma-separated ids a person actually pastes', () => {
|
||||
const { roles, invalid } = parseStaffRoles(' 123456789012345678 , 987654321098765432 ')
|
||||
assert.deepEqual(roles, ['123456789012345678', '987654321098765432'])
|
||||
assert.deepEqual(invalid, [])
|
||||
})
|
||||
|
||||
test('a typo is REPORTED, never quietly dropped', () => {
|
||||
const { invalid } = parseStaffRoles('123456789012345678, @Moderators')
|
||||
assert.deepEqual(invalid, ['@Moderators'])
|
||||
})
|
||||
|
||||
test('an empty field is a legitimate answer and not an error', () => {
|
||||
const { roles, invalid } = parseStaffRoles('')
|
||||
assert.deepEqual(roles, [])
|
||||
assert.deepEqual(invalid, [])
|
||||
})
|
||||
|
||||
test('roles round-trip through the field', () => {
|
||||
const { roles } = parseStaffRoles(formatStaffRoles(['111111111111111111', '222222222222222222']))
|
||||
assert.deepEqual(roles, ['111111111111111111', '222222222222222222'])
|
||||
})
|
||||
|
||||
// ── The status line ────────────────────────────────────────────────────────
|
||||
|
||||
test('off with channels still standing says so — the surprising case', () => {
|
||||
const text = statusSummary({ enabled: false }, [{ channelRef: '900' }, { channelRef: '901' }])
|
||||
assert.match(text, /^Off\./)
|
||||
assert.match(text, /2 channels remain/)
|
||||
})
|
||||
|
||||
test('off with nothing provisioned does not invent a warning', () => {
|
||||
const text = statusSummary({ enabled: false }, [])
|
||||
assert.match(text, /No channels are provisioned/)
|
||||
})
|
||||
|
||||
test('on states the threshold in the words the setting uses', () => {
|
||||
const text = statusSummary({ enabled: true, minMembers: 5 }, [{ channelRef: '900' }])
|
||||
assert.match(text, /at least 5 members/)
|
||||
assert.match(text, /1 provisioned/)
|
||||
})
|
||||
|
||||
test('a threshold of one is not pluralised', () => {
|
||||
assert.match(statusSummary({ enabled: true, minMembers: 1 }, []), /at least 1 member get/)
|
||||
})
|
||||
@@ -845,6 +845,561 @@ CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ─────────────────────────────
|
||||
--
|
||||
-- A Team is a core platform entity POPULATED by a module and owned by core. The
|
||||
-- module answers "what teams exist and who is in them" through the team provider
|
||||
-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and
|
||||
-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must
|
||||
-- never read or write one, even though a module is what fills them.
|
||||
--
|
||||
-- Note the tables carry no `<moduleId>_` prefix, correctly — MODULE_API.md §2.6's
|
||||
-- prefix rule binds modules, and these are core's.
|
||||
|
||||
-- The Team itself. `external_id` is the module's own stable identity for it
|
||||
-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core.
|
||||
--
|
||||
-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row
|
||||
-- with archived_reason='renamed' and creates a new one, so the old Team keeps its
|
||||
-- activity, its grants and its forum as a read-only record. What staff can change
|
||||
-- is display_name_override, which changes what is RENDERED and never what the row
|
||||
-- IS — identity and display are different things and only identity is frozen.
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
|
||||
external_id VARCHAR(191) NOT NULL, -- opaque to core
|
||||
name VARCHAR(160) NOT NULL,
|
||||
abbr VARCHAR(32) NULL,
|
||||
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
|
||||
status ENUM('active','archived') NOT NULL DEFAULT 'active',
|
||||
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
|
||||
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
|
||||
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
|
||||
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
|
||||
-- Public suppression, independent of status. A hidden Team still works
|
||||
-- completely for its own members; it is absent from public surfaces (§2.8).
|
||||
hidden TINYINT(1) NOT NULL DEFAULT 0,
|
||||
hidden_reason ENUM('reserved_name','staff') NULL,
|
||||
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
|
||||
-- Set once staff have made an explicit decision about the name. Re-screening
|
||||
-- runs on every sync, and this is what stops it re-hiding a Team a human has
|
||||
-- already allowed — without it the override would be undone every 15 minutes.
|
||||
name_reviewed_at DATETIME NULL,
|
||||
-- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per
|
||||
-- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others
|
||||
-- sync normally. Without a per-Team stamp that Team's page would claim the
|
||||
-- module's last success as its own, which is precisely the staleness the rule
|
||||
-- exists to surface. Bumped only when a roster is actually applied.
|
||||
roster_synced_at DATETIME NULL,
|
||||
-- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_
|
||||
-- since: an authoritative-but-empty ROSTER for a Team that currently has members
|
||||
-- is remembered here and applied only if the next answer agrees.
|
||||
members_empty_since DATETIME NULL,
|
||||
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
|
||||
display_name_override VARCHAR(160) NULL,
|
||||
-- The successor row written at archive time when this Team was renamed, so the
|
||||
-- old slug can still resolve and explain itself rather than 404 (§2.2).
|
||||
succeeded_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at DATETIME NULL,
|
||||
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
|
||||
-- A generated column is how "unique among ACTIVE rows only" is expressed without
|
||||
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
|
||||
-- any number of archived rows may share an external_id.
|
||||
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
|
||||
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
|
||||
UNIQUE KEY uq_teams_active (module_id, active_key),
|
||||
UNIQUE KEY uq_teams_active_slug (active_slug),
|
||||
INDEX idx_teams_status (status),
|
||||
INDEX idx_teams_slug (slug),
|
||||
INDEX idx_teams_review (hidden, hidden_reason),
|
||||
-- Self-referential and deliberately SET NULL: a successor may itself be archived
|
||||
-- and eventually pruned, and losing the pointer must not take the old row with it.
|
||||
CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
|
||||
-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
|
||||
-- deleted so history and rejoin detection survive, and so the activity feed can
|
||||
-- still name a departed member.
|
||||
--
|
||||
-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
|
||||
-- resolves it, because doing so would be core reading a module's table by name.
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id INT NOT NULL,
|
||||
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
|
||||
display_name VARCHAR(160) NULL, -- in-game name
|
||||
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
|
||||
is_leader TINYINT(1) NOT NULL DEFAULT 0,
|
||||
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
|
||||
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status ENUM('active','departed') NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
departed_at DATETIME NULL,
|
||||
PRIMARY KEY (team_id, member_key),
|
||||
-- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
|
||||
-- character from the guild — only the link to the site goes.
|
||||
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_team_members_user (user_id),
|
||||
INDEX idx_team_members_status (team_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Freshness of the module's answer. One row per module. THE table invariant 1
|
||||
-- ("module unavailability is staleness, never emptiness") is enforced against.
|
||||
CREATE TABLE IF NOT EXISTS team_sync_state (
|
||||
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
last_attempt_at DATETIME NULL,
|
||||
last_success_at DATETIME NULL,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
|
||||
-- answer is remembered here and applied only if the NEXT one agrees.
|
||||
pending_empty_since DATETIME NULL,
|
||||
INDEX idx_team_sync_success (last_success_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
|
||||
-- time. The projection is never mutated: the sync keeps writing what the game
|
||||
-- says and this keeps saying what staff decided, which is the whole point — an
|
||||
-- override the sync clobbered every 15 minutes would be useless.
|
||||
CREATE TABLE IF NOT EXISTS team_leader_overrides (
|
||||
team_id INT NOT NULL,
|
||||
member_key VARCHAR(191) NOT NULL,
|
||||
effect ENUM('grant','deny') NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
|
||||
reason VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (team_id, member_key),
|
||||
CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
|
||||
-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
|
||||
-- generated column is how "one active grant per (team,user)" is expressed without
|
||||
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
|
||||
--
|
||||
-- The table lands here, in the phase that builds the resolver, so forumAccess() is
|
||||
-- written once and its non-contamination tests are real. The grant/revoke FLOW,
|
||||
-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
|
||||
--
|
||||
-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
|
||||
-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
|
||||
-- trail of who granted whom, which is exactly what an audit exists to survive. The
|
||||
-- username snapshots keep the record readable after the account is gone.
|
||||
--
|
||||
-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
|
||||
-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
|
||||
-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
|
||||
-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
|
||||
-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
|
||||
-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
|
||||
-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
|
||||
-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
|
||||
-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_grants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
user_id INT NULL,
|
||||
username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
|
||||
granted_by INT NULL, -- NULL for a system grant, or a deleted actor
|
||||
granted_username VARCHAR(32) NULL, -- snapshot of the actor
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
reason VARCHAR(255) NULL,
|
||||
revoked_by INT NULL,
|
||||
revoked_username VARCHAR(32) NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
revoke_reason VARCHAR(255) NULL,
|
||||
active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
|
||||
UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
|
||||
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tfg_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Team forums (TEAMS.md Part 5, phase 4 "5a") ────────────────────────────
|
||||
--
|
||||
-- The WHOLE forum schema lands here, in 5a, including the columns only 5b uses.
|
||||
-- That is §5.1's split-by-layer: 5a ships the access model and announcements, 5b
|
||||
-- enables discussion by opening paths rather than by migrating data. `type`,
|
||||
-- `locked`, `pinned` and the whole post table exist from day one so that the
|
||||
-- second half adds no ALTER.
|
||||
--
|
||||
-- Every table here is guarded by `teams_forums_enabled` at the ROUTE level and
|
||||
-- never at the data level (§5.5.1). Switching the forum off must not delete a
|
||||
-- thread, revoke a grant or clear a subscription, because the operator will
|
||||
-- switch it back on and expects what they had.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_threads (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion',
|
||||
title VARCHAR(200) NOT NULL,
|
||||
created_by INT NULL, -- SET NULL: the body survives the account (§2.10)
|
||||
created_username VARCHAR(32) NULL, -- snapshot, so a deleted author still reads
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_post_at DATETIME NULL,
|
||||
post_count INT NOT NULL DEFAULT 0,
|
||||
pinned TINYINT(1) NOT NULL DEFAULT 0,
|
||||
locked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
|
||||
CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- `body_html` is sanitised ON WRITE and served without re-sanitising, the same
|
||||
-- contract the wiki and the CMS already follow — but through the FORUM's own
|
||||
-- profile (utils/forumHtml.js), not the shared one. The shared profile allows
|
||||
-- `<img>` from any host, which would make `teams_forum_images` unenforceable:
|
||||
-- every post could hotlink in every mode and the setting would be decoration.
|
||||
-- No stored body ever contains an `<img>`; core's renderer emits those at read
|
||||
-- time from the URLs the author wrote (§5.5.3), which is why flipping the policy
|
||||
-- back to `disabled` un-renders every image on every existing post with no
|
||||
-- migration at all.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_posts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
thread_id INT NOT NULL,
|
||||
author_user_id INT NULL,
|
||||
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
|
||||
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
edited_at DATETIME NULL,
|
||||
edited_by INT NULL,
|
||||
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
|
||||
CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfp_editor FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tfp_thread (thread_id, status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Append-only. Never updated, never deleted.
|
||||
--
|
||||
-- Deliberately NOT merged into the site's mod_actions/appeals pair (§5.3), which
|
||||
-- is Discord-sanction-shaped and bot-owned: routing a guild leader locking a
|
||||
-- thread through it would make ordinary housekeeping an appealable sanction with
|
||||
-- a reversal path into the bot. The two are cross-referenced instead — every
|
||||
-- STAFF-exercised action here additionally writes an activity_log row, so the
|
||||
-- site's staff-accountability trail sees it; a LEADER-exercised one writes only
|
||||
-- this ledger. `actor_role` records WHICH authority was exercised, which is the
|
||||
-- column that makes that distinction auditable after the fact.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_moderation (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
target_type ENUM('thread','post') NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
actor_username VARCHAR(32) NULL, -- snapshot (§2.10)
|
||||
actor_role ENUM('leader','staff') NOT NULL,
|
||||
reason VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfm_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tfm_target (target_type, target_id),
|
||||
INDEX idx_tfm_team (team_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Upload attribution (§5.2a, §5.5.4). Not bookkeeping: the acknowledgement an
|
||||
-- operator gives before enabling uploads is meaningless if "who uploaded this"
|
||||
-- cannot be answered afterwards, and the deletion sweep needs a row to sweep.
|
||||
--
|
||||
-- `post_id` is NULL between the upload and the post that embeds it — the composer
|
||||
-- uploads first and references the URL in the body — and that is exactly the state
|
||||
-- the orphan sweep looks for. `deleted_at` is a soft delete: the file survives a
|
||||
-- retention window so a mis-click is recoverable, then the nightly sweep removes
|
||||
-- the bytes.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_uploads (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
post_id BIGINT NULL,
|
||||
uploader_user_id INT NULL,
|
||||
uploader_username VARCHAR(32) NULL, -- snapshot: attribution must survive the account
|
||||
filename VARCHAR(255) NOT NULL, -- the STORED name, never originalname
|
||||
mimetype VARCHAR(64) NOT NULL, -- the SNIFFED type, never the client's header
|
||||
byte_size INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by INT NULL,
|
||||
CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfu_user FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfu_deleter FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_tfu_filename (filename),
|
||||
INDEX idx_tfu_uploader (uploader_user_id, created_at),
|
||||
INDEX idx_tfu_sweep (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Member-raised abuse reports (§5.6). **Core had no user-facing report flow of
|
||||
-- any kind before this**: `moderation`, `mod_notes` and `appeals` are all either
|
||||
-- staff-initiated or Discord-sanction-shaped, and nothing anywhere let a MEMBER
|
||||
-- say "this is a problem". That was survivable while every piece of content on
|
||||
-- the site came from staff. It stops being survivable the moment a Team forum
|
||||
-- lets players write to each other, and stops twice over when `uploads` mode lets
|
||||
-- them put files on the operator's disk under a signed liability acknowledgement.
|
||||
--
|
||||
-- The gap has a specific shape worth naming: leaders moderate their own Team's
|
||||
-- forum, and a Team's leaders are exactly the people who will not report their own
|
||||
-- Team. So this table's whole point is a path that routes AROUND a Team's own
|
||||
-- leadership — **reports go to site staff and to nobody else.** There is
|
||||
-- deliberately no leader-facing view of this queue (org lead, 2026-08-18); a
|
||||
-- leader-visible report about a leader is not a report.
|
||||
--
|
||||
-- Not a `team_*` table, and not named for the forum: `target_type` is a plain
|
||||
-- VARCHAR so wiki pages, news comments and profile fields become new values
|
||||
-- rather than new tables. Team forum content is only the first consumer.
|
||||
--
|
||||
-- **The unique key is on an `open_marker`, not on `status`.** §5.6 writes the key
|
||||
-- as (target_type, target_id, reporter_user_id, status), and that spelling has a
|
||||
-- defect worth recording rather than quietly fixing: it makes CLOSED rows collide
|
||||
-- with each other too. A reporter reports a post, staff dismiss it, the behaviour
|
||||
-- recurs, they report it again — and the second dismissal is an UPDATE into a
|
||||
-- (…, 'dismissed') tuple that already exists, so working the queue would start
|
||||
-- throwing duplicate-key errors after the first repeat reporter.
|
||||
--
|
||||
-- The generated marker is the same trick `team_forum_grants.active_marker` uses:
|
||||
-- it is 1 while the report is OPEN and NULL once it is closed, and MySQL treats
|
||||
-- NULLs as distinct, so any number of closed reports coexist while at most one
|
||||
-- open one can. That is what §5.6's prose actually asks for — "one open report per
|
||||
-- (target, reporter)".
|
||||
--
|
||||
-- NULL reporters (deleted accounts) are distinct for the same reason, which is
|
||||
-- also wanted: nothing should collapse two dead accounts' reports into one.
|
||||
--
|
||||
-- `handled_note` is not in the design doc and earns its place: a queue whose
|
||||
-- resolution reason lives only in an activity_log line is one where the next
|
||||
-- staffer to see a repeat report cannot find out why the last one was dismissed.
|
||||
CREATE TABLE IF NOT EXISTS content_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload'
|
||||
target_id BIGINT NOT NULL,
|
||||
team_id INT NULL, -- denormalised for the queue's filters
|
||||
reporter_user_id INT NULL,
|
||||
reporter_username VARCHAR(32) NULL, -- snapshot (§2.10): who raised it survives the account
|
||||
reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open',
|
||||
handled_by INT NULL,
|
||||
handled_username VARCHAR(32) NULL, -- snapshot, same reason
|
||||
handled_note VARCHAR(500) NULL,
|
||||
handled_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED,
|
||||
CONSTRAINT fk_cr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_cr_handler FOREIGN KEY (handled_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, open_marker),
|
||||
INDEX idx_cr_queue (status, created_at),
|
||||
INDEX idx_cr_team (team_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
|
||||
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
||||
-- performing one applies it immediately. Rows are kept after a decision — "a
|
||||
-- moderator asked to publish this name and an admin refused" is the record worth
|
||||
-- having.
|
||||
--
|
||||
-- `action` + `payload` means a fourth gated action is an enum value rather than a
|
||||
-- schema change. That is room to extend, not an invitation: nothing else is gated
|
||||
-- today, and nothing should be without asking §2.9's question first.
|
||||
CREATE TABLE IF NOT EXISTS team_moderation_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
|
||||
payload JSON NULL, -- e.g. { "displayName": "…" }
|
||||
reason VARCHAR(255) NULL,
|
||||
requested_by INT NULL,
|
||||
requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
|
||||
decided_by INT NULL,
|
||||
decided_username VARCHAR(32) NULL,
|
||||
decided_at DATETIME NULL,
|
||||
decision_note VARCHAR(255) NULL,
|
||||
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tmr_queue (status, requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
|
||||
-- core writes its own membership and rename items with source='core', and a module
|
||||
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
|
||||
-- core writes here too is deliberate — the rendering path is exercised by core's
|
||||
-- own content from day one, so the feed is never empty on a deployment whose
|
||||
-- module pushes nothing.
|
||||
--
|
||||
-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core
|
||||
-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know,
|
||||
-- and a core that templated it would have re-acquired exactly the game semantics
|
||||
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
|
||||
-- core stores and filters them, and only the module's `team.overview` slot renders
|
||||
-- anything richer than the text.
|
||||
CREATE TABLE IF NOT EXISTS team_activity (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
source VARCHAR(32) NOT NULL, -- 'core' or a module id
|
||||
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
|
||||
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
|
||||
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
|
||||
-- core ENFORCES it on the read path. Same shape as a module owning the
|
||||
-- public-safety filter for its push streams (MODULE_API.md §2.4).
|
||||
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
|
||||
actor_member_key VARCHAR(191) NULL,
|
||||
actor_user_id INT NULL,
|
||||
payload JSON NULL, -- opaque; rendered only by the module's slot
|
||||
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
|
||||
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
|
||||
-- trick shard_events already uses, and it is what makes a sidecar reconnect
|
||||
-- backfill safe: replaying a window of events re-posts nothing.
|
||||
dedupe_key CHAR(40) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
|
||||
-- Team's history of what happened, only the attribution.
|
||||
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
|
||||
INDEX idx_team_activity_feed (team_id, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not
|
||||
-- opt-in: a user in a single Team must never have to configure anything, so the
|
||||
-- absence of a row is the default and every column here is a deviation from it.
|
||||
--
|
||||
-- Team scoping lives HERE and in the recipient computation, never in a stream id.
|
||||
-- The push catalog is a static registration validated at boot against a namespaced
|
||||
-- pattern; it cannot express one stream per Team, and stream ids are stored in
|
||||
-- notification_subscriptions rows that would then need garbage-collecting every
|
||||
-- time a Team archived. Four fixed streams plus this table is the same feature
|
||||
-- with nothing to collect.
|
||||
--
|
||||
-- `last_digest_at` is the digest's ONLY state. There is no queue of pending items:
|
||||
-- the worker asks what arrived after this timestamp and re-runs the access
|
||||
-- resolver, so a deployment that was down for a day sends one correct digest
|
||||
-- rather than replaying a backlog, and a user who lost forum access between the
|
||||
-- post and the send is not emailed content they can no longer read.
|
||||
CREATE TABLE IF NOT EXISTS team_notification_prefs (
|
||||
user_id INT NOT NULL,
|
||||
team_id INT NOT NULL,
|
||||
muted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean
|
||||
-- every member of every Team starts receiving daily mail the moment an operator
|
||||
-- connects Gmail, which is a decision about other people's inboxes made on their
|
||||
-- behalf. Email is therefore the one sink here that is opt-IN; the mute is still
|
||||
-- opt-out, because a mute silences something the user already asked for.
|
||||
--
|
||||
-- It also keeps this column honest as a deviation-from-default: a row written to
|
||||
-- set `muted` alone leaves email exactly where it was.
|
||||
email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off',
|
||||
last_digest_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, team_id),
|
||||
CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- The digest worker's driving query is "rows in digest mode, oldest send first",
|
||||
-- which is a scan of this index rather than of every preference ever written.
|
||||
INDEX idx_tnp_digest (email_mode, last_digest_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── The integration bridge's configuration (TEAMS.md §7.2, phase 8) ─────────
|
||||
--
|
||||
-- The SAME events as §6, delivered to a second consumer. Not a second pipeline:
|
||||
-- `utils/teamNotify.js` computes the recipient set once and hands the event to
|
||||
-- push, to email and now to this bridge.
|
||||
--
|
||||
-- `team_id NULL` is the deployment-wide default and a per-Team row overrides it,
|
||||
-- which is what §7.2 asks for — but its `PRIMARY KEY (platform, team_id)` cannot
|
||||
-- express it: MariaDB coerces every PRIMARY KEY column to NOT NULL, so the
|
||||
-- default row is unrepresentable and the whole override mechanism has no base
|
||||
-- case. Hence the surrogate key plus a generated `team_key`, the same trick
|
||||
-- `teams.active_key` and `content_reports.open_marker` use: IFNULL folds the
|
||||
-- default row onto 0, which no `teams.id` can be, so one default and one row per
|
||||
-- Team coexist under a single UNIQUE key. It also buys the foreign key the
|
||||
-- original DDL had no room for — without it, deleting a Team leaves its bridge
|
||||
-- config behind to be inherited by the next Team that lands on the id.
|
||||
--
|
||||
-- **`members_ack` is a precondition, not a preference.** Forum posts and
|
||||
-- announcements are members-only ALWAYS — there is no public forum thread, and
|
||||
-- §7.2's gate ("visibility is public, or the channel is configured for a
|
||||
-- members-only context") has no data source on either side: the streams carry no
|
||||
-- visibility and core cannot see a Discord channel's permissions. Only the
|
||||
-- operator can. So enabling a members-only event requires an explicit, attributed
|
||||
-- acknowledgement that the destination is restricted to that Team, recorded the
|
||||
-- way `teams_forum_uploads_ack` records the image-policy one. Changing the channel
|
||||
-- CLEARS it (see the model): an acknowledgement is about a destination, and it
|
||||
-- cannot survive the destination changing underneath it.
|
||||
CREATE TABLE IF NOT EXISTS team_integration_config (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, phase 10 makes it a registry key
|
||||
team_id INT NULL, -- NULL = the deployment-wide default
|
||||
events JSON NOT NULL, -- ['team.announcement','team.forum.post']
|
||||
channel_ref VARCHAR(64) NULL, -- destination on that platform, opaque to core
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- The §7.2 gate, as an operator assertion with a name against it.
|
||||
members_ack TINYINT(1) NOT NULL DEFAULT 0,
|
||||
members_ack_by INT NULL,
|
||||
members_ack_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
team_key INT AS (IFNULL(team_id, 0)) STORED,
|
||||
UNIQUE KEY uq_tic_platform_team (platform, team_key),
|
||||
CONSTRAINT fk_tic_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- SET NULL rather than CASCADE, for the same reason every other snapshot in
|
||||
-- this file is: deleting the admin's account must not silently un-acknowledge a
|
||||
-- policy and start withholding messages the deployment is configured to send.
|
||||
CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─
|
||||
--
|
||||
-- One row per (Team, platform, resource). Today the only resource is 'voice',
|
||||
-- and the column exists because the NEXT one — a text channel, a Matrix room —
|
||||
-- is the same lifecycle with a different noun, and phase 10's capability
|
||||
-- registry needs somewhere to say which resources a platform declares.
|
||||
--
|
||||
-- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed
|
||||
-- overwrites-by-default with escalation to a role above ~90 members; the org lead
|
||||
-- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the
|
||||
-- mode transition, and it moves the ceiling: the binding limit is no longer ~100
|
||||
-- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the
|
||||
-- admin panel surfaces rather than letting a create fail into `state='error'`.
|
||||
-- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the
|
||||
-- grant itself, and a row with a channel and no role is a broken row.
|
||||
--
|
||||
-- **Two external refs, two lifetimes, and the pair is why this is a table rather
|
||||
-- than two columns on `teams`.** A channel can be deleted in Discord while the
|
||||
-- role survives, and vice versa; the reconciler has to be able to say "the role is
|
||||
-- there, the channel is not" and repair one without touching the other.
|
||||
--
|
||||
-- `state` is core's belief about Discord, never Discord's own answer: the
|
||||
-- reconciler writes what it just did, and the next pass re-derives the truth. A
|
||||
-- Team dropping below the threshold goes to 'pending_removal' with `remove_after`
|
||||
-- set rather than being deleted at once (§7.3's grace window) — a Team hovering
|
||||
-- around the threshold would otherwise delete-and-recreate, changing the channel
|
||||
-- id and breaking every pinned link to it, and a voice channel holds no message
|
||||
-- history, so the window costs nothing to keep.
|
||||
CREATE TABLE IF NOT EXISTS team_integrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10
|
||||
resource VARCHAR(32) NOT NULL, -- 'voice'
|
||||
external_ref VARCHAR(64) NULL, -- the channel id
|
||||
role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation
|
||||
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
|
||||
remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry
|
||||
last_error VARCHAR(500) NULL,
|
||||
synced_at DATETIME NULL, -- last pass that reached Discord and was believed
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_team_integration (team_id, platform, resource),
|
||||
-- Expiry is swept across every Team, so the index is on the pair the sweep
|
||||
-- filters by rather than on the Team the unique key already covers.
|
||||
INDEX idx_ti_pending (state, remove_after),
|
||||
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
@@ -875,6 +1430,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
||||
-- so the system behaves exactly as today until an admin opts in.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
||||
|
||||
-- Team forum post edit window, in minutes (TEAMS.md §5.4, phase 5). Seeded rather
|
||||
-- than left absent so the value an operator sees on the settings screen is the
|
||||
-- value in force — an empty field that silently behaves as 15 is a field nobody
|
||||
-- trusts. INSERT IGNORE, so an operator who has already changed it keeps theirs.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('teams_forum_edit_window_minutes', '15');
|
||||
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
||||
|
||||
@@ -345,6 +345,26 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/reports",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/reports/:id/handle",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/search",
|
||||
@@ -730,6 +750,247 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/archive",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/display-name",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/forum/moderation",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/grants",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/hide",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/unhide",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/settings",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/uploads",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/integrations",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/integrations",
|
||||
"handlers": 8,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/integrations/:teamId",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/requests",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/requests/:id/decide",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/resync",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/review",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/voice",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/voice",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/voice/:teamId",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/voice/sync",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uploads",
|
||||
@@ -1197,6 +1458,26 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/teams",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/teams",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions",
|
||||
@@ -1499,6 +1780,162 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/access",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/teams/:slug/forum/posts/:id",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/report",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"multerMiddleware"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads/:id",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/grants",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/grants",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/grants/:userId",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact",
|
||||
@@ -1556,6 +1993,60 @@
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug/activity",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"siteMode",
|
||||
"optionalAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug/members",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"siteMode",
|
||||
"optionalAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/version",
|
||||
@@ -1627,6 +2118,22 @@
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"requireInternalKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -145,6 +145,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/recent"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/reports"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/reports/:id/handle"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/search"
|
||||
@@ -293,6 +301,98 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/site-mode"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/archive"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/display-name"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/forum/moderation"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/grants"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/hide"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/unhide"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/settings"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/uploads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/integrations"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/integrations"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/integrations/:teamId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/requests"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/requests/:id/decide"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/resync"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/review"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/voice"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/voice"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/voice/:teamId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/voice/sync"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uploads"
|
||||
@@ -477,6 +577,14 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/teams"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions"
|
||||
@@ -605,6 +713,66 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals/eligible"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/access"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/report"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/grants"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/grants"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/grants/:userId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
@@ -637,6 +805,34 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/status"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug/activity"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug/members"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/version"
|
||||
@@ -674,6 +870,14 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/bot-config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -79,6 +79,44 @@ async function requireAuth(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
|
||||
//
|
||||
// For a PUBLIC route whose content — not merely its presentation — depends on who
|
||||
// is asking. The Team activity feed is the first: `public` items go to everyone
|
||||
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
|
||||
// an anonymous caller must be served, not rejected, and an authenticated one must
|
||||
// be identified properly.
|
||||
//
|
||||
// "Properly" is why this is not attachSession. That one decodes the token and
|
||||
// stops, which is right for reading back your own session but wrong here: a
|
||||
// banned account, a password change, or a logout would all keep working against
|
||||
// the private half of the feed until the JWT expired. This runs the same
|
||||
// database re-validation requireAuth does — status, cutoff, revocation — and on
|
||||
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
|
||||
// no longer good sees the public feed, which is exactly what they are entitled to.
|
||||
//
|
||||
// A database error also degrades to anonymous. On a public route the safe
|
||||
// direction is to serve less, and 500ing a page because a session lookup failed
|
||||
// would take the whole Team page down for callers who never sent a token.
|
||||
async function optionalAuth(req, res, next) {
|
||||
const session = sessionService.validateSession(req)
|
||||
if (!session) return next()
|
||||
try {
|
||||
const user = await users.getById(session.userId)
|
||||
if (!user) return next()
|
||||
if (user.status && user.status !== 'active') return next()
|
||||
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
|
||||
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
|
||||
|
||||
req.user = user
|
||||
req.session = session
|
||||
req.authMethod = session.authMethod
|
||||
} catch (err) {
|
||||
log.warn('optionalAuth: continuing anonymously', { message: err.message })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
|
||||
// first so req.user is populated. Use for admin-only endpoints (users, site
|
||||
// mode, settings) so a lower-privilege editor cannot reach them.
|
||||
@@ -91,6 +129,7 @@ function requireRole(...roles) {
|
||||
|
||||
module.exports = {
|
||||
attachSession,
|
||||
optionalAuth,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
//
|
||||
// What is left of config/notificationStreams.js once the shard-derived catalog
|
||||
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
|
||||
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
|
||||
// produced by the website's own posts path, not by any game feed.
|
||||
// core, the CATALOG is content). `news.post` is produced by the website's own
|
||||
// posts path, not by any game feed, and the four `team.*` streams by core's own
|
||||
// Team sync and forum.
|
||||
//
|
||||
// Registered through modules/registries.js like any module's, and read back
|
||||
// through it — nothing imports this file to get "the catalog", because the
|
||||
// catalog is core's plus every module's.
|
||||
//
|
||||
// Phase 6 added the four Team streams below. They are core's for the same reason
|
||||
// the Team tables are: a module supplies who is in a Team, but who may be told
|
||||
// about it is the access resolver's answer, and that is core's (TEAMS.md Part 6).
|
||||
//
|
||||
// The payload that ever leaves the server is a CONTENT-FREE tickle
|
||||
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
|
||||
// over the authenticated API (docs/android/PLAN.md §11).
|
||||
@@ -21,6 +26,55 @@ const STREAMS = [
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
// ── Teams (TEAMS.md §6.2, phase 6) ───────────────────────────────────────
|
||||
//
|
||||
// FOUR streams, and not one per Team. The catalog is a static registration
|
||||
// validated at boot; it has no way to express an unbounded runtime-created set,
|
||||
// and a stream id per Team would leave rows in notification_subscriptions to
|
||||
// collect every time a Team archived. Which Team an event came from lives in
|
||||
// the RECIPIENT SET (utils/teamNotify.js) and in the `ref`, never in the id.
|
||||
//
|
||||
// `requiresLinkedAccount: false` on all four is deliberate and reads oddly.
|
||||
// These are game-sourced events, so the instinct is to demand a linked game
|
||||
// account — but a forum-granted user with no game identity at all is exactly
|
||||
// the population §2.5 path 3 exists for, and they are a legitimate recipient of
|
||||
// `team.forum.post`. The flag would refuse them a toggle they have every right
|
||||
// to. What enforces who gets what is the recipient computation, which asks the
|
||||
// access resolver; the stream flag is not a second, weaker copy of that rule.
|
||||
//
|
||||
// `personal: false` for the same reason it is false on news.post: these are not
|
||||
// owner-keyed events about one account's own property. `publishToUsers` is a
|
||||
// third fan-out shape alongside "everyone subscribed" and "this one owner", and
|
||||
// the catalog has no flag for it because the flag would say nothing a caller
|
||||
// does not already know by choosing the function.
|
||||
{
|
||||
id: 'team.member.joined',
|
||||
label: 'Team — new member',
|
||||
description: 'Someone joined a Team you belong to.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.leadership.changed',
|
||||
label: 'Team — leadership change',
|
||||
description: 'Leadership changed in a Team you belong to.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.forum.post',
|
||||
label: 'Team — new forum post',
|
||||
description: 'A new thread or reply in a Team forum you can read.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.announcement',
|
||||
label: 'Team — announcements',
|
||||
description: 'A leader posted an announcement in a Team you can read.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
|
||||
@@ -48,4 +48,44 @@ const endpointsForUserStream = (userId, streamId) =>
|
||||
[userId, streamId],
|
||||
)
|
||||
|
||||
module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream }
|
||||
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
|
||||
// integer, so a null slipping into a caller's list would become user id 0 and
|
||||
// ride into an IN clause. No row has id 0, so it is harmless today — which is
|
||||
// exactly why it would never be noticed.
|
||||
const isUserId = (n) => Number.isInteger(n) && n > 0
|
||||
|
||||
// Endpoints of a COMPUTED SET of users' devices, each still gated on that user's
|
||||
// own subscription (TEAMS.md §6.2's third fan-out shape).
|
||||
//
|
||||
// The set is the whole Team-scoping mechanism: the four `team.*` streams are
|
||||
// global, and which Team an event belongs to is expressed by who is in `userIds`
|
||||
// rather than by a stream id per Team. The caller has already resolved access and
|
||||
// subtracted mutes; this function's only remaining job is to honour each
|
||||
// recipient's own opt-in, which is why the JOIN is here and not left to the
|
||||
// caller — a fan-out that skipped it would deliver to a user who had turned the
|
||||
// stream off.
|
||||
//
|
||||
// Returns [] for an empty set rather than building `IN ()`, which is a syntax
|
||||
// error in MariaDB. That case is common, not exceptional: most Team events have
|
||||
// no subscribed recipients on a deployment with no app installed at all.
|
||||
async function endpointsForUsersStream(userIds, streamId) {
|
||||
const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
|
||||
if (ids.length === 0) return []
|
||||
return query(
|
||||
`SELECT d.endpoint, d.transport
|
||||
FROM push_devices d
|
||||
JOIN notification_subscriptions s ON s.user_id = d.user_id
|
||||
WHERE s.stream_id = ? AND d.user_id IN (${ids.map(() => '?').join(',')})`,
|
||||
[streamId, ...ids],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsert,
|
||||
getByUserEndpoint,
|
||||
listByUser,
|
||||
remove,
|
||||
endpointsForStream,
|
||||
endpointsForUserStream,
|
||||
endpointsForUsersStream,
|
||||
}
|
||||
|
||||
@@ -28,5 +28,13 @@ const remove = async (id, userId) => (await db.remove(id, userId)) > 0
|
||||
// Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped).
|
||||
const endpointsForStream = (streamId) => db.endpointsForStream(streamId)
|
||||
const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId)
|
||||
const endpointsForUsersStream = (userIds, streamId) => db.endpointsForUsersStream(userIds, streamId)
|
||||
|
||||
module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream }
|
||||
module.exports = {
|
||||
register,
|
||||
listForUser,
|
||||
remove,
|
||||
endpointsForStream,
|
||||
endpointsForUserStream,
|
||||
endpointsForUsersStream,
|
||||
}
|
||||
|
||||
154
server/src/model/reports/contentReports.db.js
Normal file
154
server/src/model/reports/contentReports.db.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// SQL for `content_reports` (TEAMS.md §5.6).
|
||||
//
|
||||
// Not under model/teams/ even though Team forum content is its only consumer
|
||||
// today: the table is deliberately generic — `target_type` is a VARCHAR so that a
|
||||
// wiki page or a news comment becomes a new value rather than a new table — and
|
||||
// filing it under a feature it will outgrow is how the next consumer ends up
|
||||
// building its own.
|
||||
//
|
||||
// Nothing here decides who may read a report. That is the route's job, and there
|
||||
// is exactly one answer: site staff (§5.6, and the org lead's 2026-08-18 ruling
|
||||
// that reports are site administration only).
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLUMNS = `
|
||||
id, target_type, target_id, team_id, reporter_user_id, reporter_username,
|
||||
reason, detail, status, handled_by, handled_username, handled_note, handled_at,
|
||||
created_at`
|
||||
|
||||
const OPEN_STATUSES = ['open', 'reviewing']
|
||||
|
||||
/**
|
||||
* File a report.
|
||||
*
|
||||
* The duplicate is caught by the unique key rather than by a SELECT first, which
|
||||
* is the difference between "usually not a duplicate" and "never a duplicate":
|
||||
* two taps of a report button race, and only the index settles it. ER_DUP_ENTRY
|
||||
* comes back as a clean `null` so the caller can answer 409 without knowing what
|
||||
* a MySQL error code looks like.
|
||||
*/
|
||||
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) {
|
||||
try {
|
||||
const res = await query(
|
||||
`INSERT INTO content_reports
|
||||
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null],
|
||||
)
|
||||
return res.insertId
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function byId(id) {
|
||||
const rows = await query(`SELECT ${COLUMNS} FROM content_reports WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The queue.
|
||||
*
|
||||
* `status` defaults to the two OPEN statuses rather than to everything: a staffer
|
||||
* opening the queue wants the work, not the archive. 'all' is the explicit escape
|
||||
* hatch and every single status is selectable, so nothing is unreachable.
|
||||
*/
|
||||
async function list({ status, teamId, limit = 100, offset = 0 } = {}) {
|
||||
const where = []
|
||||
const args = []
|
||||
if (status && status !== 'all') {
|
||||
where.push('status = ?')
|
||||
args.push(status)
|
||||
} else if (!status) {
|
||||
where.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`)
|
||||
args.push(...OPEN_STATUSES)
|
||||
}
|
||||
if (teamId) {
|
||||
where.push('team_id = ?')
|
||||
args.push(teamId)
|
||||
}
|
||||
args.push(limit, offset)
|
||||
return query(
|
||||
`SELECT ${COLUMNS} FROM content_reports
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
|
||||
args,
|
||||
)
|
||||
}
|
||||
|
||||
/** How many are waiting, for the dashboard badge. */
|
||||
async function openCount() {
|
||||
const rows = await query(
|
||||
`SELECT COUNT(*) AS n FROM content_reports WHERE status IN (${OPEN_STATUSES.map(() => '?').join(',')})`,
|
||||
OPEN_STATUSES,
|
||||
)
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a staffer's decision.
|
||||
*
|
||||
* `handled_*` is stamped for every status including `reviewing`, so "who has this"
|
||||
* is answerable while it is in progress and not only after it is closed — that is
|
||||
* what stops two staffers working the same report.
|
||||
*/
|
||||
async function handle(id, { status, handledBy, handledUsername, note }) {
|
||||
const res = await query(
|
||||
`UPDATE content_reports
|
||||
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
|
||||
WHERE id = ?`,
|
||||
[status, handledBy, handledUsername, note ?? null, id],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
// ── target enrichment ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Three batched reads rather than one per row. §5.6's fourth rule — "reports on
|
||||
// uploads carry the team_forum_uploads row, so a staffer sees uploader, size and
|
||||
// sniffed type without hunting" — is the reason the queue enriches at all, and a
|
||||
// queue that N+1s to do it would be the version that gets turned off.
|
||||
|
||||
async function threadsByIds(ids) {
|
||||
if (!ids.length) return []
|
||||
return query(
|
||||
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
|
||||
WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
async function postsByIds(ids) {
|
||||
if (!ids.length) return []
|
||||
return query(
|
||||
`SELECT p.id, p.thread_id, p.author_user_id, p.author_username, p.body_html, p.status,
|
||||
p.created_at, t.team_id, t.title AS thread_title
|
||||
FROM team_forum_posts p JOIN team_forum_threads t ON t.id = p.thread_id
|
||||
WHERE p.id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
async function uploadsByIds(ids) {
|
||||
if (!ids.length) return []
|
||||
return query(
|
||||
`SELECT id, team_id, post_id, uploader_user_id, uploader_username, filename,
|
||||
mimetype, byte_size, created_at, deleted_at
|
||||
FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OPEN_STATUSES,
|
||||
insert,
|
||||
byId,
|
||||
list,
|
||||
openCount,
|
||||
handle,
|
||||
threadsByIds,
|
||||
postsByIds,
|
||||
uploadsByIds,
|
||||
}
|
||||
243
server/src/model/reports/contentReports.model.js
Normal file
243
server/src/model/reports/contentReports.model.js
Normal file
@@ -0,0 +1,243 @@
|
||||
// ── Abuse reports: the missing half of moderation (TEAMS.md §5.6) ──────────
|
||||
//
|
||||
// Two rules shape everything in this file, and both are easier to break than to
|
||||
// notice broken:
|
||||
//
|
||||
// 1. **A report is not a moderation action.** Filing one changes nothing about
|
||||
// the content — it opens a queue item. That keeps it clear of §5.3's
|
||||
// leader/staff moderation ledger, which records things that actually
|
||||
// happened. If reporting hid a post, reporting would BE moderation, and the
|
||||
// first person to work that out would have found a way to hide anything.
|
||||
//
|
||||
// 2. **Reports go to site staff and to nobody else.** The gap §5.6 exists to
|
||||
// close has a specific shape: leaders moderate their own Team's forum, and a
|
||||
// Team's leaders are exactly the people who will not report their own Team.
|
||||
// A leader-visible queue would route a complaint about a leader back to that
|
||||
// leader. The org lead settled this on 2026-08-18 — reports are **site
|
||||
// administration only**, with no leader-facing view at all, not even a
|
||||
// read-only one scoped to their own Team.
|
||||
//
|
||||
// The reporter's ACCESS is the caller's business, not this file's: the player
|
||||
// route resolves the forum first, so anyone reaching `file()` is someone who can
|
||||
// already see the thing they are reporting. What this file does check is that the
|
||||
// target is really in the Team the caller reached it through — otherwise a
|
||||
// participant in one Team could file reports carrying another Team's id, and the
|
||||
// queue's per-Team filter would quietly be lying.
|
||||
|
||||
const reportsDb = require('./contentReports.db')
|
||||
const forumDb = require('../teams/teamForum.db')
|
||||
|
||||
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
|
||||
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
|
||||
const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed']
|
||||
|
||||
// A body excerpt for the queue, not a rendered post. Staff triage on what was
|
||||
// written, and `body_html` is stored already sanitised — but the queue is a list,
|
||||
// so it gets text and a length cap rather than markup.
|
||||
const EXCERPT_CHARS = 300
|
||||
const excerpt = (html) => String(html || '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, EXCERPT_CHARS)
|
||||
|
||||
/**
|
||||
* Does this target exist, and is it in this Team?
|
||||
*
|
||||
* Returns the team id the target really belongs to, or null. The caller compares
|
||||
* it with the Team the request came through — a mismatch is a 404 for the same
|
||||
* §5.5.1 reason a foreign thread id is: confirming a target exists somewhere else
|
||||
* on the site is itself a disclosure.
|
||||
*/
|
||||
async function targetTeamId(targetType, targetId) {
|
||||
if (targetType === 'team_forum_thread') {
|
||||
const thread = await forumDb.threadById(targetId)
|
||||
return thread ? thread.team_id : null
|
||||
}
|
||||
if (targetType === 'team_forum_post') {
|
||||
const post = await forumDb.postById(targetId)
|
||||
if (!post) return null
|
||||
const thread = await forumDb.threadById(post.thread_id)
|
||||
return thread ? thread.team_id : null
|
||||
}
|
||||
if (targetType === 'team_forum_upload') {
|
||||
const upload = await forumDb.uploadById(targetId)
|
||||
return upload ? upload.team_id : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* File a report.
|
||||
*
|
||||
* A duplicate answers 409 rather than pretending to succeed. Silently accepting
|
||||
* it would be friendlier for one tap and dishonest for the second: a member who
|
||||
* reports twice because nothing seemed to happen deserves to be told the first
|
||||
* one is already in the queue.
|
||||
*/
|
||||
async function file({ team, actor, targetType, targetId, reason, detail }) {
|
||||
if (!TARGET_TYPES.includes(targetType)) {
|
||||
return { ok: false, status: 400, error: 'Unknown report target' }
|
||||
}
|
||||
if (!REASONS.includes(reason)) {
|
||||
return { ok: false, status: 400, error: 'Unknown report reason' }
|
||||
}
|
||||
|
||||
const owner = await targetTeamId(targetType, targetId)
|
||||
if (owner == null || owner !== team.id) {
|
||||
return { ok: false, status: 404, error: 'Not found' }
|
||||
}
|
||||
|
||||
const id = await reportsDb.insert({
|
||||
targetType,
|
||||
targetId,
|
||||
teamId: team.id,
|
||||
reporterUserId: actor.id,
|
||||
reporterUsername: actor.username,
|
||||
reason,
|
||||
detail,
|
||||
})
|
||||
if (id == null) {
|
||||
return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' }
|
||||
}
|
||||
return { ok: true, reportId: id }
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff queue, with each row's target attached.
|
||||
*
|
||||
* Enrichment is three batched reads keyed by target type, not one read per row.
|
||||
* The alternative N+1s a page of a hundred into three hundred queries, which is
|
||||
* how a queue becomes a thing staff avoid opening.
|
||||
*
|
||||
* A target that has since been hard-deleted comes back as `null`, and the report
|
||||
* still lists. That is deliberate: "somebody reported this and by the time we
|
||||
* looked it was gone" is a fact a moderator needs, and dropping the row would
|
||||
* hide the pattern of a member deleting their own content the moment it is
|
||||
* reported.
|
||||
*/
|
||||
async function queue({ status, teamId, limit, offset } = {}) {
|
||||
const rows = await reportsDb.list({ status, teamId, limit, offset })
|
||||
if (!rows.length) return []
|
||||
|
||||
const idsOf = (type) => rows.filter((r) => r.target_type === type).map((r) => Number(r.target_id))
|
||||
const [threads, posts, uploads] = await Promise.all([
|
||||
reportsDb.threadsByIds([...new Set(idsOf('team_forum_thread'))]),
|
||||
reportsDb.postsByIds([...new Set(idsOf('team_forum_post'))]),
|
||||
reportsDb.uploadsByIds([...new Set(idsOf('team_forum_upload'))]),
|
||||
])
|
||||
|
||||
const byId = (list) => new Map(list.map((row) => [Number(row.id), row]))
|
||||
const threadMap = byId(threads)
|
||||
const postMap = byId(posts)
|
||||
const uploadMap = byId(uploads)
|
||||
|
||||
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported content, resolved.
|
||||
*
|
||||
* **Every miss returns `null`, never `undefined`.** They look interchangeable in
|
||||
* JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so
|
||||
* a hard-deleted target would reach the client as an ABSENT `target` key rather
|
||||
* than as an explicit null, and the queue's own contract says nullable. A client
|
||||
* distinguishing "gone" from "not resolved yet" would get it wrong.
|
||||
*/
|
||||
function describeTarget(report, { threadMap, postMap, uploadMap }) {
|
||||
const id = Number(report.target_id)
|
||||
if (report.target_type === 'team_forum_thread') {
|
||||
const t = threadMap.get(id)
|
||||
if (!t) return null
|
||||
return {
|
||||
kind: 'thread',
|
||||
threadId: t.id,
|
||||
title: t.title,
|
||||
type: t.type,
|
||||
status: t.status,
|
||||
author: t.created_username,
|
||||
}
|
||||
}
|
||||
if (report.target_type === 'team_forum_post') {
|
||||
const p = postMap.get(id)
|
||||
if (!p) return null
|
||||
return {
|
||||
kind: 'post',
|
||||
postId: p.id,
|
||||
threadId: p.thread_id,
|
||||
threadTitle: p.thread_title,
|
||||
author: p.author_username,
|
||||
status: p.status,
|
||||
excerpt: excerpt(p.body_html),
|
||||
createdAt: p.created_at,
|
||||
}
|
||||
}
|
||||
if (report.target_type === 'team_forum_upload') {
|
||||
const u = uploadMap.get(id)
|
||||
if (!u) return null
|
||||
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
|
||||
// This is the payoff for §5.5.4's attribution table being load-bearing rather
|
||||
// than bookkeeping.
|
||||
return {
|
||||
kind: 'upload',
|
||||
uploadId: u.id,
|
||||
postId: u.post_id,
|
||||
uploader: u.uploader_username,
|
||||
filename: u.filename,
|
||||
url: `/uploads/${u.filename}`,
|
||||
mimetype: u.mimetype,
|
||||
byteSize: u.byte_size,
|
||||
createdAt: u.created_at,
|
||||
deleted: u.deleted_at != null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function publicReport(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
targetType: row.target_type,
|
||||
targetId: Number(row.target_id),
|
||||
teamId: row.team_id,
|
||||
reporter: row.reporter_username || '[deleted account]',
|
||||
reporterDeleted: row.reporter_user_id == null,
|
||||
reason: row.reason,
|
||||
detail: row.detail,
|
||||
status: row.status,
|
||||
handledBy: row.handled_username,
|
||||
handledNote: row.handled_note,
|
||||
handledAt: row.handled_at,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a report along the queue. Staff-only by its route. */
|
||||
async function handle({ id, actor, status, note }) {
|
||||
if (!STATUSES.includes(status)) {
|
||||
return { ok: false, status: 400, error: 'Unknown report status' }
|
||||
}
|
||||
const report = await reportsDb.byId(id)
|
||||
if (!report) return { ok: false, status: 404, error: 'Report not found' }
|
||||
|
||||
await reportsDb.handle(id, {
|
||||
status,
|
||||
handledBy: actor.id,
|
||||
handledUsername: actor.username,
|
||||
note,
|
||||
})
|
||||
return { ok: true, report: publicReport(await reportsDb.byId(id)) }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TARGET_TYPES,
|
||||
REASONS,
|
||||
STATUSES,
|
||||
EXCERPT_CHARS,
|
||||
file,
|
||||
queue,
|
||||
handle,
|
||||
openCount: reportsDb.openCount,
|
||||
publicReport,
|
||||
targetTeamId,
|
||||
}
|
||||
@@ -17,6 +17,20 @@ async function set(key, value, updatedBy = null) {
|
||||
)
|
||||
}
|
||||
|
||||
// One row WITH its provenance. `updated_by`/`updated_at` are already stored for
|
||||
// every key; this is the only reader that needs them, because TEAMS.md §5.5.5
|
||||
// makes the uploads acknowledgement a RECORDED consent rather than a displayed
|
||||
// one, and "which admin accepted it, and when" is the question that has to be
|
||||
// answerable afterwards.
|
||||
async function getRow(key) {
|
||||
const rows = await query(
|
||||
'SELECT s.`key`, s.value, s.updated_by, s.updated_at, u.username AS updated_by_username '
|
||||
+ 'FROM settings s LEFT JOIN users u ON u.id = s.updated_by WHERE s.`key` = ? LIMIT 1',
|
||||
[key],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Insert a default only if the key does not already exist.
|
||||
async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
@@ -30,4 +44,4 @@ async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault, remove }
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, remove }
|
||||
|
||||
@@ -16,6 +16,18 @@ const PUBLIC_KEYS = [
|
||||
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
|
||||
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
|
||||
'nav_public', // public site nav overrides (JSON). §6.4.
|
||||
// The two Team-forum controls (TEAMS.md §5.5.6). The client needs the first to
|
||||
// know whether to render the forum panel at all, and the second to decide which
|
||||
// composer to show — an upload control that 404s is worse than no control.
|
||||
// Neither is sensitive.
|
||||
//
|
||||
// `teams_forum_uploads_ack` is deliberately NOT here: who accepted a liability
|
||||
// notice is operator detail, exactly as `failure_reason` is in MODULE_API.md
|
||||
// §2.9. And publishing the mode does not move the DECISION client-side — the
|
||||
// server still resolves what renders (§5.5.3); the client is only told which
|
||||
// composer to draw.
|
||||
'teams_forums_enabled',
|
||||
'teams_forum_images',
|
||||
]
|
||||
|
||||
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
|
||||
|
||||
140
server/src/model/teams/teamAccess.db.js
Normal file
140
server/src/model/teams/teamAccess.db.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// SQL for the two tables the access resolver reads: forum grants (path 3) and
|
||||
// staff leadership overrides (§2.5.1).
|
||||
//
|
||||
// Kept separate from teams.db.js on purpose. The four authority paths are four
|
||||
// tables answering four questions, and the single most important structural rule
|
||||
// in TEAMS.md is that no resolver reads another path's table — a file boundary is
|
||||
// a cheap way to make crossing one visible in a diff.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── team_forum_grants (path 3) ─────────────────────────────────────────────
|
||||
|
||||
const GRANT_COLUMNS = `
|
||||
id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
|
||||
revoked_by, revoked_username, revoked_at, revoke_reason`
|
||||
|
||||
/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
|
||||
async function activeGrant(teamId, userId) {
|
||||
const rows = await query(
|
||||
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants
|
||||
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
||||
[teamId, userId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** The whole ledger for a team, revoked rows included — the admin grant view. */
|
||||
async function grantLedger(teamId) {
|
||||
return query(
|
||||
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Active grants only, for the "Forum guests" list and the per-team cap. */
|
||||
async function activeGrants(teamId) {
|
||||
return query(
|
||||
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
|
||||
ORDER BY granted_at`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */
|
||||
async function activeGrantCount(teamId) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL',
|
||||
[teamId],
|
||||
)
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a grant.
|
||||
*
|
||||
* Writes nothing but this table — that is the non-contamination invariant, and it
|
||||
* is a property of this function being the ONLY writer on the grant path rather
|
||||
* than of anyone remembering it at the call site. The username snapshots are
|
||||
* taken here so the ledger still reads after either account is deleted (§2.10).
|
||||
*/
|
||||
async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[teamId, userId, username, grantedBy, grantedUsername, reason ?? null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the active grant, if there is one.
|
||||
*
|
||||
* An UPDATE of the existing row rather than a delete: the table is a ledger as
|
||||
* well as the current state, and `revoked_at` is what moves a row out of the
|
||||
* unique key (the generated `active_marker` goes NULL) while keeping the history.
|
||||
*/
|
||||
async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) {
|
||||
const res = await query(
|
||||
`UPDATE team_forum_grants
|
||||
SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ?
|
||||
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
||||
[revokedBy, revokedUsername, reason ?? null, teamId, userId],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
|
||||
|
||||
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
||||
|
||||
async function overridesForTeam(teamId) {
|
||||
return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
|
||||
[teamId])
|
||||
}
|
||||
|
||||
async function overrideFor(teamId, memberKey) {
|
||||
const rows = await query(
|
||||
`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
|
||||
[teamId, memberKey],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or replace one override.
|
||||
*
|
||||
* The projection is never touched by this — `team_members.is_leader` keeps saying
|
||||
* what the game says and this keeps saying what staff decided, which is the entire
|
||||
* point (§2.5.1). An override applied INTO the projection would be clobbered by
|
||||
* the next sync, fifteen minutes later.
|
||||
*/
|
||||
async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
|
||||
await query(
|
||||
`INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
|
||||
actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
|
||||
[teamId, memberKey, effect, actorUserId, actorUsername, reason],
|
||||
)
|
||||
}
|
||||
|
||||
async function clearOverride(teamId, memberKey) {
|
||||
const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
|
||||
[teamId, memberKey])
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activeGrant,
|
||||
grantLedger,
|
||||
activeGrants,
|
||||
activeGrantCount,
|
||||
insertGrant,
|
||||
revokeGrant,
|
||||
overridesForTeam,
|
||||
overrideFor,
|
||||
setOverride,
|
||||
clearOverride,
|
||||
}
|
||||
131
server/src/model/teams/teamAccess.model.js
Normal file
131
server/src/model/teams/teamAccess.model.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// ── The four authority paths ───────────────────────────────────────────────
|
||||
//
|
||||
// The single most important structural rule in TEAMS.md (§2.5): these are four
|
||||
// tables answering four questions, and **no resolver reads another path's table.**
|
||||
//
|
||||
// 1. Is this account a member? module team_members
|
||||
// 2. Does this account lead the Team? module team_members.is_leader,
|
||||
// plus a staff override
|
||||
// 3. May it use the Team forum? CORE team_forum_grants OR path 1
|
||||
// 4. May it get external-platform CORE, nothing of its own
|
||||
// access? derived
|
||||
//
|
||||
// The temptation this file exists to resist is collapsing 1 and 3 into one
|
||||
// boolean. They answer different questions about different populations: a forum
|
||||
// grant may name any Runic Gateway account, including one with no game identity
|
||||
// at all — that is the point of it, since letting an unlinked guildmate into the
|
||||
// forum must not require a staff ticket. Treating "has forum access" as "is a
|
||||
// member" would put that person on the roster, in the member count, and into the
|
||||
// external-platform grant, which is where it stops being a modelling preference
|
||||
// and becomes an impersonation risk (path 4 below).
|
||||
//
|
||||
// Non-contamination is the invariant: a manual grant never writes the membership
|
||||
// projection, in either direction, ever. Both facts coexist and neither migrates
|
||||
// into the other.
|
||||
|
||||
const accessDb = require('./teamAccess.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const identities = require('../userIdentities/userIdentities.model')
|
||||
|
||||
/**
|
||||
* Path 3 — forum access. Two reads, OR'd, and nothing else.
|
||||
*
|
||||
* `viaGrant` is reported even when membership also holds, deliberately: both
|
||||
* facts are true, the UI presents membership as the current reason, and the grant
|
||||
* survives as audit history. Collapsing them into one boolean is what loses the
|
||||
* record of who let this person in and why.
|
||||
*/
|
||||
async function forumAccess(teamId, userId) {
|
||||
if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false }
|
||||
|
||||
const [grant, member] = await Promise.all([
|
||||
accessDb.activeGrant(teamId, userId), // path 3's own table
|
||||
teamsDb.activeByUser(teamId, userId), // path 1
|
||||
])
|
||||
|
||||
return {
|
||||
allowed: Boolean(grant) || Boolean(member),
|
||||
viaMembership: Boolean(member),
|
||||
viaGrant: Boolean(grant),
|
||||
isLeader: member ? await isLeader(teamId, member) : false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path 2 — leadership, with the staff override applied ON TOP of the synced value
|
||||
* at read time (§2.5.1).
|
||||
*
|
||||
* Applied at read rather than written into the projection because the sync owns
|
||||
* that column and rewrites it every interval. An override that lived in
|
||||
* `team_members` would be undone fifteen minutes after staff set it, which is the
|
||||
* whole reason this is a separate table read here.
|
||||
*/
|
||||
async function isLeader(teamId, member) {
|
||||
if (!member) return false
|
||||
const override = await accessDb.overrideFor(teamId, member.member_key)
|
||||
if (override) return override.effect === 'grant'
|
||||
return Boolean(member.is_leader)
|
||||
}
|
||||
|
||||
/** Leadership for a caller identified by user id rather than by a member row. */
|
||||
async function isLeaderByUser(teamId, userId) {
|
||||
if (!userId) return false
|
||||
const member = await teamsDb.activeByUser(teamId, userId)
|
||||
return isLeader(teamId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
* Path 4 — external-platform eligibility. Computed, no table of its own, and
|
||||
* deliberately blind to path 3.
|
||||
*
|
||||
* The reason, stated so nobody "fixes" it later: an integration cannot verify
|
||||
* that an unlinked, forum-granted account corresponds to a real game member, so
|
||||
* it must not hand that account a privilege on a platform where impersonation has
|
||||
* consequences. A forum is a room on the operator's own site with a known
|
||||
* moderator; a Discord role is an identity claim in someone else's space.
|
||||
*/
|
||||
async function externalEligible(teamId, userId, platform) {
|
||||
if (!userId || !platform) return false
|
||||
const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY
|
||||
if (!member || member.user_id == null) return false // must be a LINKED game member
|
||||
const linked = await identities.listForUser(userId)
|
||||
return linked.some((i) => i.provider === platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* A team's roster with overrides folded in, for the admin view and the Team page.
|
||||
*
|
||||
* The rows returned carry `is_leader` as RESOLVED — synced value plus override —
|
||||
* and `is_leader_synced` as what the game actually said, so the admin surface can
|
||||
* show that a decision was made rather than silently presenting it as fact.
|
||||
*/
|
||||
async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
|
||||
const [members, overrides] = await Promise.all([
|
||||
teamsDb.membersByTeam(teamId, { includeDeparted }),
|
||||
accessDb.overridesForTeam(teamId),
|
||||
])
|
||||
const byKey = new Map(overrides.map((o) => [o.member_key, o]))
|
||||
return members.map((m) => {
|
||||
const override = byKey.get(m.member_key)
|
||||
return {
|
||||
...m,
|
||||
is_leader_synced: Boolean(m.is_leader),
|
||||
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
|
||||
leader_override: override
|
||||
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
|
||||
: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
forumAccess,
|
||||
isLeader,
|
||||
isLeaderByUser,
|
||||
externalEligible,
|
||||
rosterWithOverrides,
|
||||
setLeaderOverride: accessDb.setOverride,
|
||||
clearLeaderOverride: accessDb.clearOverride,
|
||||
grantLedger: accessDb.grantLedger,
|
||||
activeGrants: accessDb.activeGrants,
|
||||
}
|
||||
133
server/src/model/teams/teamActivity.db.js
Normal file
133
server/src/model/teams/teamActivity.db.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
|
||||
// decision about what a caller may SEE lives in teamActivity.model.js.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const ACTIVITY_COLUMNS = `
|
||||
id, team_id, source, kind, summary, visibility,
|
||||
actor_member_key, actor_user_id, payload, occurred_at, created_at`
|
||||
|
||||
/**
|
||||
* Insert one item, idempotently when it carries a dedupe key.
|
||||
*
|
||||
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
|
||||
* sidecar reconnect backfills a window of events it already delivered, and
|
||||
* without this every reconnect would double-post the feed. The same trick
|
||||
* `shard_events` uses, for the same reason.
|
||||
*
|
||||
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
|
||||
* a unique index, so items WITHOUT a key never collide with each other — an
|
||||
* un-keyed push is always an insert, which is the documented contract (§4.1:
|
||||
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
|
||||
*
|
||||
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
|
||||
* model validates and truncates before calling, so what reaches here can only fail
|
||||
* on the dedupe key, and `affectedRows` reports which happened.
|
||||
*/
|
||||
async function insert(item) {
|
||||
const res = await query(
|
||||
`INSERT IGNORE INTO team_activity
|
||||
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
item.teamId,
|
||||
item.source,
|
||||
item.kind,
|
||||
item.summary,
|
||||
item.visibility,
|
||||
item.actorMemberKey,
|
||||
item.actorUserId,
|
||||
item.payload === null ? null : JSON.stringify(item.payload),
|
||||
new Date(item.occurredAt),
|
||||
item.dedupeKey,
|
||||
],
|
||||
)
|
||||
return Number(res.affectedRows) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a Team's feed, already narrowed to the visibilities the caller may
|
||||
* see.
|
||||
*
|
||||
* `visibilities` is always supplied by the model and never by a request
|
||||
* parameter — a caller naming its own visibility filter is the whole bug this
|
||||
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
|
||||
* game's clock, not `created_at`: a backfill that arrives late still sorts where
|
||||
* it happened.
|
||||
*/
|
||||
async function page(teamId, visibilities, { limit, offset }) {
|
||||
const slots = visibilities.map(() => '?').join(', ')
|
||||
return query(
|
||||
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
|
||||
WHERE team_id = ? AND visibility IN (${slots})
|
||||
ORDER BY occurred_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[teamId, ...visibilities, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
/** Total matching rows, for the same filter — so a client can page honestly. */
|
||||
async function count(teamId, visibilities) {
|
||||
const slots = visibilities.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
|
||||
[teamId, ...visibilities],
|
||||
)
|
||||
return Number(rows[0] ? rows[0].n : 0)
|
||||
}
|
||||
|
||||
/** Everything older than the retention horizon, across every Team. */
|
||||
async function deleteOlderThan(days) {
|
||||
const res = await query(
|
||||
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
|
||||
[days],
|
||||
)
|
||||
return Number(res.affectedRows) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Teams currently exceed the per-Team row cap, and by how much.
|
||||
*
|
||||
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
|
||||
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
|
||||
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
|
||||
* problem rather than to the number of Teams.
|
||||
*/
|
||||
async function overCap(cap) {
|
||||
return query(
|
||||
`SELECT team_id, COUNT(*) AS n FROM team_activity
|
||||
GROUP BY team_id HAVING n > ?`,
|
||||
[cap],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim one Team back to the newest `cap` rows.
|
||||
*
|
||||
* Expressed as "delete everything at or below the id of the cap-th newest row"
|
||||
* rather than as a correlated subquery on the same table, which MariaDB refuses
|
||||
* inside a DELETE (error 1093). The derived table is what makes it legal — the
|
||||
* subquery is materialised before the delete runs.
|
||||
*/
|
||||
async function trimToCap(teamId, cap) {
|
||||
const rows = await query(
|
||||
`SELECT id FROM team_activity
|
||||
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
|
||||
[teamId, cap],
|
||||
)
|
||||
if (!rows[0]) return 0
|
||||
const res = await query(
|
||||
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
|
||||
[teamId, rows[0].id],
|
||||
)
|
||||
return Number(res.affectedRows) || 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insert,
|
||||
page,
|
||||
count,
|
||||
deleteOlderThan,
|
||||
overCap,
|
||||
trimToCap,
|
||||
}
|
||||
312
server/src/model/teams/teamActivity.model.js
Normal file
312
server/src/model/teams/teamActivity.model.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// ── The per-Team activity feed (TEAMS.md Part 4) ───────────────────────────
|
||||
//
|
||||
// Two writers, one table. A module pushes game items through
|
||||
// `ctx.teams.activity.push` (§4.1); core writes its own membership and rename
|
||||
// items directly (§4.2). Both land in `team_activity` with a `source`, and the
|
||||
// read path treats them identically — which is the point of core writing here at
|
||||
// all, since it means the rendering path is exercised from day one on a
|
||||
// deployment whose module pushes nothing.
|
||||
//
|
||||
// **Three rules shape this file.**
|
||||
//
|
||||
// 1. *Core never composes a summary.* `summary` arrives already rendered and is
|
||||
// stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game
|
||||
// whose vocabulary it does not know, and a core that templated it would have
|
||||
// re-acquired the game semantics the module system exists to remove. Core's OWN
|
||||
// five kinds are the sole exception, and they are about membership and renames
|
||||
// — platform facts, not game ones.
|
||||
//
|
||||
// 2. *Visibility fails closed.* An item with no stated visibility is `members`,
|
||||
// and the read path resolves what a caller may see from their access rather
|
||||
// than from anything they send.
|
||||
//
|
||||
// 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited
|
||||
// by a module inside a game-event handler. A bad item is dropped and logged;
|
||||
// an unknown Team is dropped and logged. The alternative — rejecting the batch
|
||||
// — makes core's storage problem into the module's control flow, and the
|
||||
// contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget.
|
||||
|
||||
const activityDb = require('./teamActivity.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const access = require('./teamAccess.model')
|
||||
const settings = require('../settings/settings.model')
|
||||
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
// Column widths from schema.sql. Truncating rather than refusing: an over-long
|
||||
// summary is a module being verbose, not a module being wrong, and dropping the
|
||||
// item would lose a real event over a display detail.
|
||||
const MAX_SUMMARY = 255
|
||||
const MAX_KIND = 64
|
||||
const MAX_MEMBER_KEY = 191
|
||||
// CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like,
|
||||
// but the column is opaque and any stable string within the width works.
|
||||
const MAX_DEDUPE = 40
|
||||
|
||||
const VISIBILITIES = ['public', 'members']
|
||||
|
||||
// Retention (§4.2). Both are settings so an operator can tighten a busy shard
|
||||
// without a deploy; the defaults are the doc's.
|
||||
const DEFAULT_RETAIN_DAYS = 90
|
||||
const DEFAULT_ROW_CAP = 2000
|
||||
|
||||
/**
|
||||
* Core's own kinds (§4.2).
|
||||
*
|
||||
* `core.forum.thread` is named in the doc and lands with the forum in phase 4 —
|
||||
* there is nothing to emit it from yet. The four here are all core knows how to
|
||||
* say without asking a game anything.
|
||||
*/
|
||||
const CORE_KINDS = {
|
||||
MEMBER_JOINED: 'core.member.joined',
|
||||
MEMBER_LEFT: 'core.member.left',
|
||||
LEADER_CHANGED: 'core.leader.changed',
|
||||
TEAM_RENAMED: 'core.team.renamed',
|
||||
}
|
||||
|
||||
const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null)
|
||||
|
||||
/**
|
||||
* Normalise one pushed item, or return null to drop it.
|
||||
*
|
||||
* `teamId` is resolved by the caller, not carried on the item: a module names its
|
||||
* own `externalId` and core maps it (§4.1), so a module can never write into
|
||||
* another module's Team by guessing an integer.
|
||||
*/
|
||||
function normalise(item, source, teamId) {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
|
||||
const kind = clamp(item.kind, MAX_KIND)
|
||||
const summary = clamp(item.summary, MAX_SUMMARY)
|
||||
// Both are load-bearing and neither has a safe default: an item with no kind
|
||||
// cannot be filtered or rendered by a slot, and one with no summary is a blank
|
||||
// row on a public page.
|
||||
if (!kind || !summary) return null
|
||||
|
||||
// `occurredAt` is the game's clock and the feed's sort key. A missing or
|
||||
// unparseable one becomes now — the item is real even when its timestamp is
|
||||
// not, and dropping it would lose an event over metadata.
|
||||
const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now()
|
||||
|
||||
return {
|
||||
teamId,
|
||||
source,
|
||||
kind,
|
||||
summary,
|
||||
visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members',
|
||||
actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY),
|
||||
// Resolved BY THE MODULE, like every other user id crossing this boundary
|
||||
// (§2.3) — core takes the number and never looks it up.
|
||||
actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null,
|
||||
payload: item.payload && typeof item.payload === 'object' ? item.payload : null,
|
||||
occurredAt,
|
||||
dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.teams.activity.push` — a module's whole write access to the feed.
|
||||
*
|
||||
* Items name their Team by the module's own `externalId`, and only ACTIVE Teams
|
||||
* owned by THAT module resolve. An archived Team is deliberately not writable: its
|
||||
* feed is a read-only record of what happened before the rename or the disband
|
||||
* (§2.2), and letting a late-arriving event append to it would make a closed
|
||||
* record grow.
|
||||
*
|
||||
* Returns the number of items actually stored. Dropped items are logged with the
|
||||
* reason and never raised — see rule 3 above.
|
||||
*/
|
||||
async function push(source, items) {
|
||||
if (!Array.isArray(items)) {
|
||||
log.warn('teams activity push: not an array', { source })
|
||||
return 0
|
||||
}
|
||||
if (!items.length) return 0
|
||||
|
||||
// One lookup per distinct externalId, not one per item: a champion spawn
|
||||
// completing pushes a batch for a single Team, and re-resolving it per item
|
||||
// would be a query per row.
|
||||
const teamIds = new Map()
|
||||
let stored = 0
|
||||
let dropped = 0
|
||||
|
||||
for (const item of items) {
|
||||
const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : ''
|
||||
if (!externalId) { dropped += 1; continue }
|
||||
|
||||
if (!teamIds.has(externalId)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const row = await teamsDb.findActive(source, externalId)
|
||||
teamIds.set(externalId, row ? row.id : null)
|
||||
}
|
||||
const teamId = teamIds.get(externalId)
|
||||
if (!teamId) { dropped += 1; continue }
|
||||
|
||||
const normalised = normalise(item, source, teamId)
|
||||
if (!normalised) { dropped += 1; continue }
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const inserted = await activityDb.insert(normalised)
|
||||
// A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism
|
||||
// working. Counted as stored so a module replaying a backfill does not read
|
||||
// its own idempotence as data loss.
|
||||
if (inserted) stored += 1
|
||||
}
|
||||
|
||||
if (dropped) {
|
||||
log.warn('teams activity push: dropped items', { source, dropped, offered: items.length })
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's own write path (§4.2), used by the reconciler and the rename rule.
|
||||
*
|
||||
* Separate from `push` because core names a Team by its own primary key — it is
|
||||
* already holding the row — and because core's items are always `public`: a
|
||||
* member joining or a Team being renamed is exactly what a public Team page is
|
||||
* for. Nothing here is game vocabulary.
|
||||
*/
|
||||
async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) {
|
||||
if (!teamId || !kind || !summary) return false
|
||||
return activityDb.insert({
|
||||
teamId,
|
||||
source: 'core',
|
||||
kind: clamp(kind, MAX_KIND),
|
||||
summary: clamp(summary, MAX_SUMMARY),
|
||||
visibility: 'public',
|
||||
actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY),
|
||||
actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null,
|
||||
payload: null,
|
||||
occurredAt,
|
||||
dedupeKey: clamp(dedupeKey, MAX_DEDUPE),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which visibilities a caller may see (§4.3).
|
||||
*
|
||||
* `members` items go to members and to forum-granted users — the same two
|
||||
* authority paths `forumAccess` already resolves, reused rather than re-derived
|
||||
* so the feed can never disagree with the forum about who is inside a Team.
|
||||
* Anyone else, including every anonymous caller, sees `public` only.
|
||||
*/
|
||||
async function visibilitiesFor(teamId, userId) {
|
||||
if (!userId) return ['public']
|
||||
const resolved = await access.forumAccess(teamId, userId)
|
||||
return resolved.allowed ? ['public', 'members'] : ['public']
|
||||
}
|
||||
|
||||
/** The rendered shape. `payload` rides along for the module's slot (§4.3). */
|
||||
function publicItem(row) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
source: row.source,
|
||||
kind: row.kind,
|
||||
summary: row.summary,
|
||||
visibility: row.visibility,
|
||||
occurredAt: row.occurred_at,
|
||||
payload: row.payload ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a Team's feed for one viewer.
|
||||
*
|
||||
* A HIDDEN Team's feed is not served publicly, for the same reason its roster is
|
||||
* not (§2.8.3): hidden means absent from every public surface, and a feed that
|
||||
* answered while the page 404s would republish the suppressed name in every
|
||||
* `core.team.renamed` summary.
|
||||
*/
|
||||
async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row) return null
|
||||
|
||||
const visibilities = await visibilitiesFor(row.id, userId)
|
||||
// A member of a hidden Team still sees its feed — suppression is a
|
||||
// public-surface rule, and a member is not a member of the public (§2.11).
|
||||
if (row.hidden && visibilities.length === 1) return null
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
activityDb.page(row.id, visibilities, { limit, offset }),
|
||||
activityDb.count(row.id, visibilities),
|
||||
])
|
||||
return {
|
||||
items: rows.map(publicItem),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
// So a client can render "members-only items are hidden" rather than
|
||||
// presenting a filtered feed as the whole one.
|
||||
scope: visibilities.includes('members') ? 'members' : 'public',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retention (§4.2) ───────────────────────────────────────────────────────
|
||||
|
||||
const RETAIN_KEY = 'team_activity_retain_days'
|
||||
const CAP_KEY = 'team_activity_row_cap'
|
||||
|
||||
/**
|
||||
* Read both limits, falling back to the defaults on anything unreadable.
|
||||
*
|
||||
* Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this
|
||||
* runs on a timer with nobody watching, and a settings table that is briefly
|
||||
* unavailable must yield the default rather than an exception that kills the
|
||||
* nightly job. A misconfigured value fails the same way — a zero or a negative
|
||||
* retention would delete the whole feed, so it is rejected rather than honoured.
|
||||
*/
|
||||
async function retentionConfig() {
|
||||
let rawDays
|
||||
let rawCap
|
||||
try {
|
||||
;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)])
|
||||
} catch {
|
||||
return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP }
|
||||
}
|
||||
const days = Number.parseInt(rawDays, 10)
|
||||
const cap = Number.parseInt(rawCap, 10)
|
||||
return {
|
||||
days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS,
|
||||
cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The nightly prune: an age horizon AND a per-Team row cap.
|
||||
*
|
||||
* Both, because either alone has a hole. Age alone lets one busy guild write a
|
||||
* million rows inside the window; a cap alone keeps a dead Team's feed forever.
|
||||
* Unbounded growth on a per-Team feed fed by a game loop is the obvious failure
|
||||
* here and it is cheaper to bound it now than to discover it at cutover.
|
||||
*/
|
||||
async function prune() {
|
||||
const { days, cap } = await retentionConfig()
|
||||
const byAge = await activityDb.deleteOlderThan(days)
|
||||
|
||||
let byCap = 0
|
||||
const over = await activityDb.overCap(cap)
|
||||
for (const row of over) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
byCap += await activityDb.trimToCap(row.team_id, cap)
|
||||
}
|
||||
|
||||
if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap })
|
||||
return { byAge, byCap, days, cap }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
push,
|
||||
logCore,
|
||||
feedFor,
|
||||
visibilitiesFor,
|
||||
publicItem,
|
||||
prune,
|
||||
retentionConfig,
|
||||
RETAIN_KEY,
|
||||
CAP_KEY,
|
||||
CORE_KINDS,
|
||||
VISIBILITIES,
|
||||
DEFAULT_RETAIN_DAYS,
|
||||
DEFAULT_ROW_CAP,
|
||||
}
|
||||
294
server/src/model/teams/teamForum.db.js
Normal file
294
server/src/model/teams/teamForum.db.js
Normal file
@@ -0,0 +1,294 @@
|
||||
// SQL for the four forum tables (TEAMS.md §5.2, §5.2a).
|
||||
//
|
||||
// Kept apart from teamAccess.db.js for the same reason that file is kept apart
|
||||
// from teams.db.js: forum CONTENT and forum ACCESS are different questions, and a
|
||||
// query here that read `team_members` to decide who may see a thread would be the
|
||||
// exact collapse §2.5 forbids. Nothing in this file resolves access; callers hand
|
||||
// it a decision the resolver already made.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const THREAD_COLUMNS = `
|
||||
id, team_id, type, title, created_by, created_username, created_at,
|
||||
last_post_at, post_count, pinned, locked, status`
|
||||
|
||||
const POST_COLUMNS = `
|
||||
id, thread_id, author_user_id, author_username, body_html, created_at,
|
||||
edited_at, edited_by, status`
|
||||
|
||||
// ── threads ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A Team's threads, newest activity first with pinned rows on top.
|
||||
*
|
||||
* `includeHidden` is the staff/leader view. Hidden is not deleted: a hidden
|
||||
* thread stays in the ledger and comes back with `unhide`, which is why the
|
||||
* status filter is a parameter rather than a WHERE clause everyone remembers.
|
||||
*/
|
||||
async function threadsByTeam(teamId, { includeHidden = false, limit = 50, offset = 0 } = {}) {
|
||||
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
|
||||
return query(
|
||||
`SELECT ${THREAD_COLUMNS} FROM team_forum_threads
|
||||
WHERE team_id = ? AND status IN ${statuses}
|
||||
ORDER BY pinned DESC, COALESCE(last_post_at, created_at) DESC, id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[teamId, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
async function threadById(id) {
|
||||
const rows = await query(`SELECT ${THREAD_COLUMNS} FROM team_forum_threads WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function insertThread({ teamId, type, title, createdBy, createdUsername }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_forum_threads (team_id, type, title, created_by, created_username, last_post_at, post_count)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), 0)`,
|
||||
[teamId, type, title, createdBy, createdUsername],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/** Apply one moderation action's effect. The LEDGER row is written separately. */
|
||||
async function setThreadFlags(id, { pinned, locked, status }) {
|
||||
const sets = []
|
||||
const args = []
|
||||
if (pinned !== undefined) { sets.push('pinned = ?'); args.push(pinned ? 1 : 0) }
|
||||
if (locked !== undefined) { sets.push('locked = ?'); args.push(locked ? 1 : 0) }
|
||||
if (status !== undefined) { sets.push('status = ?'); args.push(status) }
|
||||
if (!sets.length) return false
|
||||
args.push(id)
|
||||
const res = await query(`UPDATE team_forum_threads SET ${sets.join(', ')} WHERE id = ?`, args)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
// ── posts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function postsByThread(threadId, { includeHidden = false } = {}) {
|
||||
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
|
||||
return query(
|
||||
`SELECT ${POST_COLUMNS} FROM team_forum_posts
|
||||
WHERE thread_id = ? AND status IN ${statuses} ORDER BY created_at, id`,
|
||||
[threadId],
|
||||
)
|
||||
}
|
||||
|
||||
async function postById(id) {
|
||||
const rows = await query(`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a post and move the thread's counters in the same breath.
|
||||
*
|
||||
* Two statements rather than a trigger: the counters are a denormalisation for
|
||||
* the thread list, and a trigger would put half the write in the schema where
|
||||
* nobody reading this file would find it.
|
||||
*/
|
||||
async function insertPost({ threadId, authorUserId, authorUsername, bodyHtml }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_forum_posts (thread_id, author_user_id, author_username, body_html)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[threadId, authorUserId, authorUsername, bodyHtml],
|
||||
)
|
||||
await query(
|
||||
'UPDATE team_forum_threads SET post_count = post_count + 1, last_post_at = NOW() WHERE id = ?',
|
||||
[threadId],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function setPostStatus(id, status) {
|
||||
const res = await query('UPDATE team_forum_posts SET status = ? WHERE id = ?', [status, id])
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a post's body, stamping who edited it and when.
|
||||
*
|
||||
* `edited_at` is set unconditionally, including when a staffer edits — the column
|
||||
* answers "has this been changed since it was written", which a reader needs to
|
||||
* know regardless of whose hand did it. `edited_by` is the second half of that
|
||||
* answer and is why the two are separate columns rather than a boolean.
|
||||
*/
|
||||
async function updatePostBody(id, bodyHtml, editedBy) {
|
||||
const res = await query(
|
||||
'UPDATE team_forum_posts SET body_html = ?, edited_at = NOW(), edited_by = ? WHERE id = ?',
|
||||
[bodyHtml, editedBy, id],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute a thread's denormalised counters from the posts that are actually
|
||||
* visible.
|
||||
*
|
||||
* Called after every post moderation rather than incrementing and decrementing,
|
||||
* because hide → unhide → delete → restore is a sequence in which a counter kept
|
||||
* by deltas drifts the first time any step is retried or raced. The read is one
|
||||
* indexed aggregate over one thread; correctness is worth more than the write it
|
||||
* saves. `last_post_at` falls back to NULL for an emptied thread, which is what
|
||||
* `threadsByTeam`'s COALESCE onto `created_at` already expects.
|
||||
*/
|
||||
async function recountThread(threadId) {
|
||||
await query(
|
||||
`UPDATE team_forum_threads t
|
||||
SET t.post_count = (SELECT COUNT(*) FROM team_forum_posts p
|
||||
WHERE p.thread_id = t.id AND p.status = 'visible'),
|
||||
t.last_post_at = (SELECT MAX(p.created_at) FROM team_forum_posts p
|
||||
WHERE p.thread_id = t.id AND p.status = 'visible')
|
||||
WHERE t.id = ?`,
|
||||
[threadId],
|
||||
)
|
||||
}
|
||||
|
||||
// ── the moderation ledger (append-only) ────────────────────────────────────
|
||||
|
||||
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
|
||||
await query(
|
||||
`INSERT INTO team_forum_moderation
|
||||
(team_id, target_type, target_id, action, actor_user_id, actor_username, actor_role, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason ?? null],
|
||||
)
|
||||
}
|
||||
|
||||
async function moderationForTeam(teamId, { limit = 100, offset = 0 } = {}) {
|
||||
return query(
|
||||
`SELECT id, team_id, target_type, target_id, action, actor_user_id, actor_username,
|
||||
actor_role, reason, created_at
|
||||
FROM team_forum_moderation WHERE team_id = ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
|
||||
[teamId, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
// ── uploads (§5.2a) ────────────────────────────────────────────────────────
|
||||
|
||||
const UPLOAD_COLUMNS = `
|
||||
id, team_id, post_id, uploader_user_id, uploader_username, filename, mimetype,
|
||||
byte_size, created_at, deleted_at, deleted_by`
|
||||
|
||||
async function insertUpload({ teamId, postId, uploaderUserId, uploaderUsername, filename, mimetype, byteSize }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_forum_uploads
|
||||
(team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, byte_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[teamId, postId ?? null, uploaderUserId, uploaderUsername, filename, mimetype, byteSize],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function uploadById(id) {
|
||||
const rows = await query(`SELECT ${UPLOAD_COLUMNS} FROM team_forum_uploads WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Bytes this account has uploaded in the trailing window — the §5.5.4 daily quota. */
|
||||
async function bytesUploadedSince(userId, sinceHours) {
|
||||
const rows = await query(
|
||||
`SELECT COALESCE(SUM(byte_size), 0) AS bytes FROM team_forum_uploads
|
||||
WHERE uploader_user_id = ? AND created_at > (NOW() - INTERVAL ? HOUR)`,
|
||||
[userId, sinceHours],
|
||||
)
|
||||
return Number(rows[0]?.bytes || 0)
|
||||
}
|
||||
|
||||
/** The admin attribution view: who uploaded what, when, how much, and where. */
|
||||
async function listUploads({ limit = 100, offset = 0, includeDeleted = false } = {}) {
|
||||
return query(
|
||||
`SELECT u.id, u.team_id, u.post_id, u.uploader_user_id, u.uploader_username,
|
||||
u.filename, u.mimetype, u.byte_size, u.created_at, u.deleted_at, u.deleted_by,
|
||||
t.name AS team_name, t.slug AS team_slug
|
||||
FROM team_forum_uploads u JOIN teams t ON t.id = u.team_id
|
||||
${includeDeleted ? '' : 'WHERE u.deleted_at IS NULL'}
|
||||
ORDER BY u.created_at DESC, u.id DESC LIMIT ? OFFSET ?`,
|
||||
[limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
async function softDeleteUpload(id, deletedBy) {
|
||||
const res = await query(
|
||||
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL',
|
||||
[deletedBy, id],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
/** Soft-delete every upload attached to a post — the lifecycle half of §5.5.4. */
|
||||
async function softDeleteUploadsForPost(postId, deletedBy) {
|
||||
await query(
|
||||
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE post_id = ? AND deleted_at IS NULL',
|
||||
[deletedBy, postId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the pair: a restored post gets its images back.
|
||||
*
|
||||
* Without this, `delete` then `restore` returns the words and loses the pictures —
|
||||
* and loses them SILENTLY, because the soft-deleted rows survive the retention
|
||||
* window before the sweep takes the bytes, so the post looks fine until the night
|
||||
* it does not. Beyond that window the row itself is gone and this is a no-op;
|
||||
* nothing can be done about that and nothing should pretend otherwise.
|
||||
*/
|
||||
async function restoreUploadsForPost(postId) {
|
||||
await query(
|
||||
'UPDATE team_forum_uploads SET deleted_at = NULL, deleted_by = NULL WHERE post_id = ? AND deleted_at IS NOT NULL',
|
||||
[postId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
|
||||
async function sweepableUploads(retentionDays) {
|
||||
return query(
|
||||
`SELECT id, filename FROM team_forum_uploads
|
||||
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)`,
|
||||
[retentionDays],
|
||||
)
|
||||
}
|
||||
|
||||
/** Never-referenced uploads older than the grace period — a composer opened and abandoned. */
|
||||
async function orphanedUploads(graceHours) {
|
||||
return query(
|
||||
`SELECT id, filename FROM team_forum_uploads
|
||||
WHERE post_id IS NULL AND deleted_at IS NULL AND created_at < (NOW() - INTERVAL ? HOUR)`,
|
||||
[graceHours],
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteUploadRows(ids) {
|
||||
if (!ids.length) return 0
|
||||
const res = await query(
|
||||
`DELETE FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
return res.affectedRows
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
threadsByTeam,
|
||||
threadById,
|
||||
insertThread,
|
||||
setThreadFlags,
|
||||
postsByThread,
|
||||
postById,
|
||||
insertPost,
|
||||
setPostStatus,
|
||||
updatePostBody,
|
||||
recountThread,
|
||||
insertModeration,
|
||||
moderationForTeam,
|
||||
insertUpload,
|
||||
uploadById,
|
||||
bytesUploadedSince,
|
||||
listUploads,
|
||||
softDeleteUpload,
|
||||
softDeleteUploadsForPost,
|
||||
restoreUploadsForPost,
|
||||
sweepableUploads,
|
||||
orphanedUploads,
|
||||
deleteUploadRows,
|
||||
}
|
||||
437
server/src/model/teams/teamForum.model.js
Normal file
437
server/src/model/teams/teamForum.model.js
Normal file
@@ -0,0 +1,437 @@
|
||||
// ── The forum: access + announcements (5a), discussion + moderation (5b) ───
|
||||
//
|
||||
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a shipped the whole access
|
||||
// model and a single announcements stream per Team; 5b (phase 5) opens discussion
|
||||
// threads, replies, editing and post-level moderation. The schema for all of it
|
||||
// landed together, so this phase added no ALTER — every column it needed
|
||||
// (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the
|
||||
// ledger's `target_type='post'`) was already there waiting.
|
||||
//
|
||||
// **Every function here takes an already-resolved access decision.** Nothing in
|
||||
// this file reads `team_members` or `team_forum_grants`; the caller asks
|
||||
// teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never
|
||||
// by checking membership directly, which is how paths 1 and 3 would drift back
|
||||
// together", made structural.
|
||||
//
|
||||
// **The read path is where the image policy is applied**, once, in `renderPost`.
|
||||
// Not in the controller and never in the client: the client is TOLD the mode so it
|
||||
// can draw the right composer, and is never the thing that decides whether an
|
||||
// image appears (§5.5.6).
|
||||
|
||||
const forumDb = require('./teamForum.db')
|
||||
const forumSettings = require('./teamForumSettings.model')
|
||||
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
|
||||
|
||||
// Announcements are leader-authored and take no replies; discussion threads are
|
||||
// member-authored and do. Both have been in the enum since 5a — what phase 5
|
||||
// changed is that both are now CREATABLE, and by different people.
|
||||
//
|
||||
// **The authority split lives in the controller, not here.** This list says what
|
||||
// kinds of thread exist; who may make one is a question about the caller, which
|
||||
// this file deliberately never asks (see the header on access decisions).
|
||||
const CREATABLE_TYPES = ['announcement', 'discussion']
|
||||
|
||||
// Kept as an export because it names a real fact — the one type 5a could create —
|
||||
// and because removing a name from a module's surface to save a line is how a
|
||||
// consumer outside this repo breaks. It is not used to decide anything.
|
||||
const CREATABLE_TYPES_5A = ['announcement']
|
||||
|
||||
// Which thread types accept replies. An announcement's `locked` stays false even
|
||||
// though nothing may reply to it: replies are refused because the TYPE takes none,
|
||||
// not because the thread was closed, and conflating the two would make "unlock"
|
||||
// look like it would open replies on an announcement.
|
||||
const REPLYABLE_TYPES = ['discussion']
|
||||
|
||||
const DELETED_AUTHOR = '[deleted account]'
|
||||
|
||||
/**
|
||||
* Moderation actions, and what each one does to the row.
|
||||
*
|
||||
* A table rather than a switch because the ledger and the effect have to stay in
|
||||
* step: every entry here writes one row of `team_forum_moderation` naming the
|
||||
* authority that was exercised, and an action with an effect but no ledger entry
|
||||
* would be a moderation nobody can audit.
|
||||
*/
|
||||
const THREAD_ACTIONS = {
|
||||
pin: { pinned: true },
|
||||
unpin: { pinned: false },
|
||||
lock: { locked: true },
|
||||
unlock: { locked: false },
|
||||
hide: { status: 'hidden' },
|
||||
unhide: { status: 'visible' },
|
||||
delete: { status: 'deleted' },
|
||||
restore: { status: 'visible' },
|
||||
}
|
||||
|
||||
// Post-level moderation. A strict subset of THREAD_ACTIONS: `pin` and `lock`
|
||||
// describe a thread's place in a list and its openness to replies, neither of
|
||||
// which a post has. Naming them here as "not applicable" rather than as "unknown"
|
||||
// is what lets `moderatePost` tell a caller which mistake they made.
|
||||
const POST_ACTIONS = {
|
||||
hide: { status: 'hidden' },
|
||||
unhide: { status: 'visible' },
|
||||
delete: { status: 'deleted' },
|
||||
restore: { status: 'visible' },
|
||||
}
|
||||
|
||||
function publicThread(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
author: row.created_username || DELETED_AUTHOR,
|
||||
authorDeleted: row.created_by == null,
|
||||
createdAt: row.created_at,
|
||||
lastPostAt: row.last_post_at,
|
||||
postCount: row.post_count,
|
||||
pinned: Boolean(row.pinned),
|
||||
locked: Boolean(row.locked),
|
||||
status: row.status,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* May this viewer edit this post, and until when?
|
||||
*
|
||||
* **Computed on the server and handed to the client, never the other way round** —
|
||||
* the same rule §5.5.3 applies to the image policy, for the same reason. A client
|
||||
* that decided this would be deciding it against its own clock, and a clock is the
|
||||
* one input a time-bounded permission must not take from the party it bounds.
|
||||
*
|
||||
* Staff get `editableUntil: null`, which reads as "no deadline" rather than as "no
|
||||
* permission" — `canEdit` is the permission and this is only its expiry. An author
|
||||
* past their window keeps a past `editableUntil`, so the UI can say *why* the
|
||||
* control is gone instead of silently dropping it.
|
||||
*/
|
||||
function editability(row, { userId = null, isStaff = false, windowMinutes = 0, now = Date.now() } = {}) {
|
||||
// A hidden or deleted post is not editable by anybody, staff included. Restoring
|
||||
// it is a moderation action with a ledger row; quietly rewriting it while it is
|
||||
// out of sight is the same act with no record.
|
||||
if (row.status !== 'visible') return { canEdit: false, editableUntil: null }
|
||||
if (isStaff) return { canEdit: true, editableUntil: null }
|
||||
if (!userId || row.author_user_id == null || row.author_user_id !== userId) {
|
||||
return { canEdit: false, editableUntil: null }
|
||||
}
|
||||
const until = new Date(row.created_at).getTime() + windowMinutes * 60_000
|
||||
return { canEdit: until > now, editableUntil: new Date(until).toISOString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* One post, rendered for one image policy and one viewer.
|
||||
*
|
||||
* `body` is what the reader gets and `mode` decides whether it carries images.
|
||||
* The STORED html is never modified — flipping the policy changes this function's
|
||||
* output and nothing on disk, which is the property §5.5.3 exists to give and the
|
||||
* one acceptance criterion 3 measures.
|
||||
*
|
||||
* `viewer` is optional so that every 5a caller keeps working unchanged; omitting
|
||||
* it yields `canEdit: false`, which is the right answer for a caller that has not
|
||||
* said who is reading.
|
||||
*/
|
||||
function renderPost(row, mode, viewer) {
|
||||
return {
|
||||
id: row.id,
|
||||
author: row.author_username || DELETED_AUTHOR,
|
||||
authorDeleted: row.author_user_id == null,
|
||||
body: renderForumBody(row.body_html, mode),
|
||||
createdAt: row.created_at,
|
||||
editedAt: row.edited_at,
|
||||
status: row.status,
|
||||
mine: Boolean(viewer?.userId) && row.author_user_id === viewer.userId,
|
||||
...editability(row, viewer),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread list for one viewer.
|
||||
*
|
||||
* `canModerate` widens what is returned, not just what is offered: a hidden
|
||||
* thread is visible to the people who can unhide it and to nobody else, so the
|
||||
* same call answers both audiences without a second endpoint that could disagree
|
||||
* with this one.
|
||||
*/
|
||||
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
|
||||
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
|
||||
return rows.map(publicThread)
|
||||
}
|
||||
|
||||
/**
|
||||
* One thread with its posts, rendered under the current image policy and for one
|
||||
* viewer.
|
||||
*
|
||||
* `viewer` carries who is reading and what the edit window is, so every post comes
|
||||
* back already knowing whether this caller may edit it. The alternative — shipping
|
||||
* the window to the client and letting it compare timestamps — is the thing
|
||||
* `editability` exists not to do.
|
||||
*/
|
||||
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
|
||||
const thread = await forumDb.threadById(threadId)
|
||||
// The team check is here rather than in the SQL so a thread id from another
|
||||
// Team reads as "not found" and not as "found, but not yours" — a forum is a
|
||||
// private room and the existence of a thread in it is itself private.
|
||||
if (!thread || thread.team_id !== teamId) return null
|
||||
if (thread.status === 'deleted' && !canModerate) return null
|
||||
if (thread.status === 'hidden' && !canModerate) return null
|
||||
|
||||
const mode = await forumSettings.imageMode()
|
||||
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
|
||||
return {
|
||||
...publicThread(thread),
|
||||
// A reply control is offered when the TYPE takes replies and the thread is
|
||||
// open. Both halves are reported separately (`type`, `locked`) so the UI can
|
||||
// say which one is why, but the decision itself is made here — a client that
|
||||
// recomputed it would be a second place for the rule to live.
|
||||
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
|
||||
posts: posts.map((p) => renderPost(p, mode, viewer)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a thread: the thread and its first post, in one call.
|
||||
*
|
||||
* An announcement is a degenerate thread rather than its own thing (§5.1), which
|
||||
* is why phase 5 added no migration — a discussion thread is the same two writes
|
||||
* with a different `type`. The FIRST post is an ordinary post and is moderated,
|
||||
* edited and reported like any other; nothing here marks it as special, because a
|
||||
* thread whose opening post could not be moderated would be a hole shaped exactly
|
||||
* like the one moderation exists to close.
|
||||
*/
|
||||
async function createThread({ team, actor, type, title, body }) {
|
||||
if (!CREATABLE_TYPES.includes(type)) {
|
||||
return { ok: false, status: 400, error: 'Unknown thread type' }
|
||||
}
|
||||
const cleaned = cleanForumBody(body)
|
||||
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
||||
return { ok: false, status: 400, error: 'A post needs a body' }
|
||||
}
|
||||
const threadId = await forumDb.insertThread({
|
||||
teamId: team.id,
|
||||
type,
|
||||
title,
|
||||
createdBy: actor.id,
|
||||
createdUsername: actor.username,
|
||||
})
|
||||
const postId = await forumDb.insertPost({
|
||||
threadId,
|
||||
authorUserId: actor.id,
|
||||
authorUsername: actor.username,
|
||||
bodyHtml: cleaned,
|
||||
})
|
||||
// `notify` is what the CONTROLLER needs to fan a notification out, and it is a
|
||||
// separate key rather than more fields on the result because the controller
|
||||
// spreads the result straight into the response body — a notification's excerpt
|
||||
// is not part of the API's answer to "did my post save".
|
||||
//
|
||||
// The notification itself is fired from the controller and not from here, on
|
||||
// this file's own rule (see the header): everything in it takes an
|
||||
// already-resolved access decision and reads no membership table. The fan-out
|
||||
// reads both, so importing it here would make the forum model transitively
|
||||
// depend on exactly what it exists not to touch.
|
||||
return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a discussion thread.
|
||||
*
|
||||
* Three refusals, and the status codes are chosen to be distinguishable rather
|
||||
* than uniform. A thread that is not there, or is hidden from this caller, is 404
|
||||
* for the §5.5.1 reason. An announcement is 400 — the request is malformed for
|
||||
* this thread, and no amount of retrying fixes it. A locked thread is **409**: the
|
||||
* request is fine and the resource's state is what refuses, which is exactly the
|
||||
* distinction a client needs to tell "you cannot" from "not right now".
|
||||
*
|
||||
* **Locked refuses staff too.** They hold `unlock`, so nothing is lost — and what
|
||||
* is gained is that `locked` means the same thing to every reader. A moderator's
|
||||
* reply appearing in a thread nobody else may answer is the last word by fiat;
|
||||
* unlock, post, relock is the same outcome with three ledger rows saying so.
|
||||
*/
|
||||
async function createPost({ team, threadId, actor, body }) {
|
||||
const thread = await forumDb.threadById(threadId)
|
||||
if (!thread || thread.team_id !== team.id || thread.status !== 'visible') {
|
||||
return { ok: false, status: 404, error: 'Thread not found' }
|
||||
}
|
||||
if (!REPLYABLE_TYPES.includes(thread.type)) {
|
||||
return { ok: false, status: 400, error: 'Announcements do not take replies' }
|
||||
}
|
||||
if (thread.locked) {
|
||||
return { ok: false, status: 409, error: 'This thread is locked' }
|
||||
}
|
||||
const cleaned = cleanForumBody(body)
|
||||
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
||||
return { ok: false, status: 400, error: 'A reply needs a body' }
|
||||
}
|
||||
const postId = await forumDb.insertPost({
|
||||
threadId,
|
||||
authorUserId: actor.id,
|
||||
authorUsername: actor.username,
|
||||
bodyHtml: cleaned,
|
||||
})
|
||||
// The thread's OWN title and type, not the reply's — a reply has neither, and
|
||||
// what a recipient needs to know is which conversation moved. `type` is always
|
||||
// 'discussion' here (an announcement takes no replies) and is carried anyway so
|
||||
// the controller has one shape to hand the fan-out from both routes.
|
||||
return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a post: the author inside the window, staff at any time (§5.4).
|
||||
*
|
||||
* The window is re-derived HERE from `created_at` and never trusted from the
|
||||
* request, which is also why `editability` runs on the read path — the read tells
|
||||
* the client whether to draw the control, and this decides whether the edit
|
||||
* happens. Two evaluations of one rule, deliberately: the read one is advice and
|
||||
* this one is enforcement.
|
||||
*
|
||||
* A staffer editing someone else's post is reported back as `staffEdit` so the
|
||||
* controller can write the §5.3 accountability row. A staffer editing their OWN
|
||||
* post is an ordinary edit and is not: the trail records interventions, and
|
||||
* everything a staffer ever typed is not an intervention.
|
||||
*/
|
||||
async function editPost({ team, postId, actor, isStaff = false, windowMinutes = 0, body }) {
|
||||
const post = await forumDb.postById(postId)
|
||||
if (!post) return { ok: false, status: 404, error: 'Post not found' }
|
||||
|
||||
const thread = await forumDb.threadById(post.thread_id)
|
||||
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
|
||||
if (post.status !== 'visible' || thread.status !== 'visible') {
|
||||
return { ok: false, status: 404, error: 'Post not found' }
|
||||
}
|
||||
|
||||
const isAuthor = post.author_user_id != null && post.author_user_id === actor.id
|
||||
if (!isAuthor && !isStaff) {
|
||||
return { ok: false, status: 403, error: 'You may only edit your own posts' }
|
||||
}
|
||||
if (!isStaff) {
|
||||
if (thread.locked) return { ok: false, status: 409, error: 'This thread is locked' }
|
||||
const { canEdit } = editability(post, { userId: actor.id, windowMinutes })
|
||||
if (!canEdit) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: windowMinutes > 0
|
||||
? `The ${windowMinutes}-minute edit window for this post has closed`
|
||||
: 'Posts cannot be edited on this site',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = cleanForumBody(body)
|
||||
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
||||
return { ok: false, status: 400, error: 'A post needs a body' }
|
||||
}
|
||||
await forumDb.updatePostBody(postId, cleaned, actor.id)
|
||||
return { ok: true, postId, threadId: post.thread_id, staffEdit: isStaff && !isAuthor }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a moderation action to a thread, and record WHICH authority did it.
|
||||
*
|
||||
* `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary
|
||||
* housekeeping distinguishable from a staff intervention after the fact (§5.3).
|
||||
* The caller resolves it; this function records it and never infers it, because
|
||||
* an actor who is both would otherwise be recorded as whichever the code checked
|
||||
* first.
|
||||
*/
|
||||
async function moderateThread({ team, threadId, action, actor, actorRole, reason }) {
|
||||
const effect = THREAD_ACTIONS[action]
|
||||
if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' }
|
||||
|
||||
const thread = await forumDb.threadById(threadId)
|
||||
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' }
|
||||
|
||||
await forumDb.setThreadFlags(threadId, effect)
|
||||
await forumDb.insertModeration({
|
||||
teamId: team.id,
|
||||
targetType: 'thread',
|
||||
targetId: threadId,
|
||||
action,
|
||||
actorUserId: actor.id,
|
||||
actorUsername: actor.username,
|
||||
actorRole,
|
||||
reason,
|
||||
})
|
||||
return { ok: true, action, threadId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a moderation action to a POST, and record which authority did it.
|
||||
*
|
||||
* The same ledger as `moderateThread`, with `target_type='post'` — one table, two
|
||||
* target kinds, because "show me everything that was moderated in this Team" is
|
||||
* the question the admin view asks and two tables would make it a union.
|
||||
*
|
||||
* `pin` and `unpin`, `lock` and `unlock` are refused with a message that names the
|
||||
* mistake rather than a bare "unknown action": they are real actions applied to
|
||||
* the wrong kind of object, and a caller who sent one has a bug worth telling
|
||||
* them about precisely.
|
||||
*
|
||||
* **The opening post of a thread is moderatable like any other.** Hiding it leaves
|
||||
* a thread with a title and its replies and no body, which looks odd and is
|
||||
* correct — an abusive opener does not have to take a good discussion with it, and
|
||||
* a moderator who wants the whole thing gone has `hide` on the thread.
|
||||
*/
|
||||
async function moderatePost({ team, postId, action, actor, actorRole, reason }) {
|
||||
const effect = POST_ACTIONS[action]
|
||||
if (!effect) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: THREAD_ACTIONS[action]
|
||||
? `"${action}" applies to a thread, not to a post`
|
||||
: 'Unknown moderation action',
|
||||
}
|
||||
}
|
||||
|
||||
const post = await forumDb.postById(postId)
|
||||
if (!post) return { ok: false, status: 404, error: 'Post not found' }
|
||||
const thread = await forumDb.threadById(post.thread_id)
|
||||
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
|
||||
|
||||
await forumDb.setPostStatus(postId, effect.status)
|
||||
// The counters are recomputed rather than nudged, because these four actions
|
||||
// form cycles (hide → unhide → hide) that a delta gets wrong the first time one
|
||||
// is retried.
|
||||
await forumDb.recountThread(post.thread_id)
|
||||
|
||||
// Images follow their post. Soft on the way out and reversible on the way back
|
||||
// in, so `delete` → `restore` inside the retention window returns the post
|
||||
// whole; past it, the sweep has taken the bytes and nothing can.
|
||||
if (action === 'delete') await forumDb.softDeleteUploadsForPost(postId, actor.id)
|
||||
if (action === 'restore') await forumDb.restoreUploadsForPost(postId)
|
||||
|
||||
await forumDb.insertModeration({
|
||||
teamId: team.id,
|
||||
targetType: 'post',
|
||||
targetId: postId,
|
||||
action,
|
||||
actorUserId: actor.id,
|
||||
actorUsername: actor.username,
|
||||
actorRole,
|
||||
reason,
|
||||
})
|
||||
return { ok: true, action, postId, threadId: post.thread_id }
|
||||
}
|
||||
|
||||
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
|
||||
async function moderationLedger(teamId, opts) {
|
||||
return forumDb.moderationForTeam(teamId, opts)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CREATABLE_TYPES,
|
||||
CREATABLE_TYPES_5A,
|
||||
REPLYABLE_TYPES,
|
||||
THREAD_ACTIONS,
|
||||
POST_ACTIONS,
|
||||
listThreads,
|
||||
getThread,
|
||||
createThread,
|
||||
createPost,
|
||||
editPost,
|
||||
moderateThread,
|
||||
moderatePost,
|
||||
moderationLedger,
|
||||
publicThread,
|
||||
renderPost,
|
||||
editability,
|
||||
}
|
||||
197
server/src/model/teams/teamForumSettings.model.js
Normal file
197
server/src/model/teams/teamForumSettings.model.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// ── The operator's forum controls, and the acknowledgement gate ────────────
|
||||
//
|
||||
// TEAMS.md §5.5, plus phase 5's edit window. Four `settings` keys, and the reason
|
||||
// they live in their own file rather than in settings.model.js is that only two
|
||||
// of them are ordinary keys: `teams_forum_images` has a server-side precondition,
|
||||
// and a precondition buried in the generic setMany() loop is one nobody reading
|
||||
// that loop would know about.
|
||||
//
|
||||
// teams_forums_enabled '0' | '1' default '0' — off
|
||||
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
|
||||
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
|
||||
// teams_forum_edit_window_minutes 0 … 1440 default 15 (phase 5)
|
||||
//
|
||||
// **Every read fails closed.** A DB fault reports the forum off, images disabled
|
||||
// and the edit window shut, because the alternative is a transient error opening a
|
||||
// feature the operator turned off, or rendering third-party images on a site whose
|
||||
// operator chose not to. The cost of failing closed here is a forum that 404s for a
|
||||
// minute; the cost of failing open is a policy that is not a policy.
|
||||
|
||||
const settingsDb = require('../settings/settings.db')
|
||||
|
||||
const ENABLED_KEY = 'teams_forums_enabled'
|
||||
const IMAGES_KEY = 'teams_forum_images'
|
||||
const ACK_KEY = 'teams_forum_uploads_ack'
|
||||
const EDIT_WINDOW_KEY = 'teams_forum_edit_window_minutes'
|
||||
|
||||
const IMAGE_MODES = ['disabled', 'remote', 'uploads']
|
||||
|
||||
// How long an author may edit their own post. Staff are not bound by it (§5.4).
|
||||
const EDIT_WINDOW_DEFAULT = 15
|
||||
const EDIT_WINDOW_MAX = 1440 // a day; beyond that "window" stops meaning anything
|
||||
|
||||
// The version of the §5.5.5 warning text currently in force. Bumping this is what
|
||||
// makes every stored acknowledgement stale — see `ackState` below for what that
|
||||
// then does, which is deliberately NOT "turn uploads off".
|
||||
const ACK_VERSION = '1'
|
||||
|
||||
/** Is the forum switched on? Fail closed. */
|
||||
async function forumsEnabled() {
|
||||
try {
|
||||
return String(await settingsDb.get(ENABLED_KEY)) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The image policy. Fail closed, and coerce any unexpected stored value back to
|
||||
* 'disabled' — a hand-edited row must not be able to widen the policy by being
|
||||
* unreadable.
|
||||
*/
|
||||
async function imageMode() {
|
||||
try {
|
||||
const value = await settingsDb.get(IMAGES_KEY)
|
||||
return IMAGE_MODES.includes(value) ? value : 'disabled'
|
||||
} catch {
|
||||
return 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many minutes an author has to edit their own post.
|
||||
*
|
||||
* Fails closed to ZERO rather than to the default, and that is the opposite of
|
||||
* what it looks like it should do. The risk an edit window bounds is an author
|
||||
* rewriting a post out from under a reader who is quoting it or a moderator who
|
||||
* is about to act on a report — so the safe answer during a DB fault is "nobody
|
||||
* may edit for the next minute", not "everyone may edit for fifteen". Staff are
|
||||
* unaffected either way, because their authority is not time-bounded.
|
||||
*
|
||||
* `0` is also a legitimate STORED value, meaning an operator who wants posts
|
||||
* immutable once written. There is deliberately no distinction between "off" and
|
||||
* "unreadable" here: both deny, and inventing a third state would only give the
|
||||
* caller a decision to get wrong.
|
||||
*/
|
||||
async function editWindowMinutes() {
|
||||
try {
|
||||
const raw = await settingsDb.get(EDIT_WINDOW_KEY)
|
||||
if (raw == null || raw === '') return EDIT_WINDOW_DEFAULT
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n < 0 || n > EDIT_WINDOW_MAX) return EDIT_WINDOW_DEFAULT
|
||||
return Math.floor(n)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
|
||||
async function uploadsEnabled() {
|
||||
return (await imageMode()) === 'uploads'
|
||||
}
|
||||
|
||||
/**
|
||||
* The acknowledgement's state, for the admin surface.
|
||||
*
|
||||
* `stale` is the case §5.5.5 spends its longest paragraph on: the text was
|
||||
* reworded after an operator accepted it. Neither obvious answer is right —
|
||||
* silently downgrading a live feature because a legal text changed strands users
|
||||
* mid-conversation, and honouring an old acceptance forever defeats versioning.
|
||||
* So uploads keep working, `stale` drives a persistent banner, and
|
||||
* `assertSettingsWritable` below refuses every other forum setting until it is
|
||||
* re-given. Non-destructive, and impossible to ignore.
|
||||
*/
|
||||
async function ackState() {
|
||||
const stored = await settingsDb.get(ACK_KEY)
|
||||
const row = await settingsDb.getRow(ACK_KEY)
|
||||
return {
|
||||
version: ACK_VERSION,
|
||||
acknowledgedVersion: stored ?? null,
|
||||
given: stored != null,
|
||||
stale: stored != null && String(stored) !== ACK_VERSION,
|
||||
...(row ? { acknowledgedBy: row.updated_by_username ?? null, acknowledgedAt: row.updated_at } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate. `PUT teams_forum_images = 'uploads'` is rejected 400 unless the SAME
|
||||
* request carries `acknowledge: <currentVersion>`.
|
||||
*
|
||||
* The checkbox in the admin UI is not the gate — it is how the gate is presented.
|
||||
* That distinction is the whole reason this function exists on the server: an
|
||||
* acknowledgement a client could skip is not an acknowledgement.
|
||||
*
|
||||
* Returns `{ ok }` or `{ ok: false, error, status }`, matching the model result
|
||||
* shape the Teams controllers already translate.
|
||||
*/
|
||||
async function assertAcknowledged(nextMode, acknowledge) {
|
||||
if (nextMode !== 'uploads') return { ok: true }
|
||||
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
|
||||
|
||||
// **The gate is on SELECTING uploads, not on the value being present.**
|
||||
//
|
||||
// A settings form sends every field it owns, so once uploads is on, every later
|
||||
// save re-sends `uploads` — turning the forum off, switching back to `remote`,
|
||||
// any of it. Demanding a fresh acknowledgement for those would make the mode a
|
||||
// one-way door: the operator could never change a forum setting again, and the
|
||||
// one thing they would most want to do in a hurry (switch the forum off) would
|
||||
// be the thing refused. Found on the live rig, where unticking "Enable Team
|
||||
// forums" came back 400.
|
||||
//
|
||||
// So an acknowledgement already ON RECORD, for the version in force, while
|
||||
// uploads is ALREADY the stored mode, is what this request needs — there is no
|
||||
// new consent to take. A transition INTO uploads still needs the checkbox, and
|
||||
// a stale acknowledgement is caught by assertSettingsWritable, which is the
|
||||
// separate rule for a reworded notice.
|
||||
const [state, current] = await Promise.all([ackState(), imageMode()])
|
||||
if (current === 'uploads' && state.given && !state.stale) return { ok: true }
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: `Enabling uploads requires acknowledging the current notice (version ${ACK_VERSION}).`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stale-acknowledgement lock: while an acknowledgement is stale, NO forum
|
||||
* setting may be saved until it is re-given. Not "uploads are disabled" — see
|
||||
* `ackState`. The re-acknowledgement itself is exempt, or the lock would have no
|
||||
* key.
|
||||
*/
|
||||
async function assertSettingsWritable(keys, acknowledge) {
|
||||
const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY || k === EDIT_WINDOW_KEY)
|
||||
if (!touchesForum) return { ok: true }
|
||||
const state = await ackState()
|
||||
if (!state.stale) return { ok: true }
|
||||
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: 'The image-upload notice has changed. Re-acknowledge it before saving forum settings.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the acknowledgement. `updated_by`/`updated_at` come free from the settings schema. */
|
||||
async function recordAck(adminUserId) {
|
||||
await settingsDb.set(ACK_KEY, ACK_VERSION, adminUserId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENABLED_KEY,
|
||||
IMAGES_KEY,
|
||||
ACK_KEY,
|
||||
EDIT_WINDOW_KEY,
|
||||
IMAGE_MODES,
|
||||
ACK_VERSION,
|
||||
EDIT_WINDOW_DEFAULT,
|
||||
EDIT_WINDOW_MAX,
|
||||
forumsEnabled,
|
||||
imageMode,
|
||||
editWindowMinutes,
|
||||
uploadsEnabled,
|
||||
ackState,
|
||||
assertAcknowledged,
|
||||
assertSettingsWritable,
|
||||
recordAck,
|
||||
}
|
||||
177
server/src/model/teams/teamForumUploads.model.js
Normal file
177
server/src/model/teams/teamForumUploads.model.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// ── `uploads` mode, and what had to harden first (TEAMS.md §5.5.4) ─────────
|
||||
//
|
||||
// The existing admin upload path (router/v1/admin/imageUpload.js) is already good
|
||||
// for an admin: an 8 MB cap, a mimetype allowlist, a random filename, an extension
|
||||
// derived from the MIMETYPE MAP and never from `originalname`, and
|
||||
// `X-Content-Type-Options: nosniff` forced on serve. All of that is kept and this
|
||||
// file adds the four things that path never needed, because until now it has never
|
||||
// had a hostile uploader.
|
||||
//
|
||||
// 1. MAGIC-BYTE SNIFFING. `file.mimetype` is the client's own Content-Type
|
||||
// header. A player can send `image/png` with arbitrary bytes and land
|
||||
// arbitrary content under a `.png`. Trusted from an admin, not from a player.
|
||||
// 2. QUOTAS. A per-post attachment cap and a per-account daily byte quota.
|
||||
// Community uploads with no ceiling is disk exhaustion on the operator's own
|
||||
// host. (The per-request RATE limit is core's rateLimit middleware, applied
|
||||
// at the route.)
|
||||
// 3. ATTRIBUTION. Every accepted file gets a `team_forum_uploads` row. Not
|
||||
// bookkeeping: the acknowledgement in §5.5.5 is meaningless if "who uploaded
|
||||
// this" cannot be answered afterwards.
|
||||
// 4. LIFECYCLE. Deleting a post soft-deletes its uploads; the sweep removes the
|
||||
// bytes after a retention window, and files with no row at all. The admin
|
||||
// upload path never deletes anything, which is fine at admin volume and is
|
||||
// not fine here.
|
||||
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
|
||||
const forumDb = require('./teamForum.db')
|
||||
const { UPLOAD_DIR } = require('../../router/v1/admin/imageUpload')
|
||||
|
||||
// Leading bytes → the type they actually are. Deliberately not a library: five
|
||||
// signatures, checked exactly, is less surface than a dependency that accepts
|
||||
// hundreds of formats when the allowlist only wants these.
|
||||
//
|
||||
// WebP and AVIF are container formats, so both need a second check past the first
|
||||
// four bytes — RIFF alone is also .wav, and the `ftyp` box also fronts .mp4.
|
||||
const SIGNATURES = [
|
||||
{ mime: 'image/png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
||||
{ mime: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
||||
{ mime: 'image/gif', test: (b) => b.subarray(0, 6).toString('latin1').match(/^GIF8[79]a$/) != null },
|
||||
{
|
||||
mime: 'image/webp',
|
||||
test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
|
||||
},
|
||||
{
|
||||
mime: 'image/avif',
|
||||
test: (b) => b.subarray(4, 8).toString('latin1') === 'ftyp'
|
||||
&& ['avif', 'avis'].includes(b.subarray(8, 12).toString('latin1')),
|
||||
},
|
||||
]
|
||||
|
||||
// Per-post attachment cap and per-account rolling byte quota.
|
||||
const MAX_ATTACHMENTS_PER_POST = 6
|
||||
const DAILY_QUOTA_BYTES = 25 * 1024 * 1024
|
||||
const QUOTA_WINDOW_HOURS = 24
|
||||
|
||||
// Lifecycle windows. A soft-deleted file survives long enough for a mis-click to
|
||||
// be recoverable; an orphan is one uploaded into a composer that was never
|
||||
// submitted, which is a normal thing to do and so gets a generous grace.
|
||||
const RETENTION_DAYS = 30
|
||||
const ORPHAN_GRACE_HOURS = 48
|
||||
|
||||
/**
|
||||
* What do these bytes actually claim to be?
|
||||
*
|
||||
* Returns the sniffed mimetype, or null when nothing matches. Null is a rejection
|
||||
* and never a "trust the header instead" — an unrecognised file is exactly the
|
||||
* case this check exists for.
|
||||
*/
|
||||
function sniff(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
|
||||
return SIGNATURES.find((s) => s.test(buffer))?.mime || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a file multer has already written to disk.
|
||||
*
|
||||
* The file is on disk before it can be sniffed — multer streams it there — so the
|
||||
* rejection path has to REMOVE it. A rejected upload that stays on disk is exactly
|
||||
* the disk-exhaustion vector the quota exists to close, reached by a different
|
||||
* route.
|
||||
*/
|
||||
async function accept({ team, actor, file }) {
|
||||
const stored = path.join(UPLOAD_DIR, file.filename)
|
||||
const discard = async () => { await fs.rm(stored, { force: true }) }
|
||||
|
||||
let head
|
||||
try {
|
||||
const handle = await fs.open(stored, 'r')
|
||||
try {
|
||||
head = Buffer.alloc(16)
|
||||
await handle.read(head, 0, 16, 0)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch {
|
||||
await discard()
|
||||
return { ok: false, status: 400, error: 'Could not read the uploaded file' }
|
||||
}
|
||||
|
||||
const sniffed = sniff(head)
|
||||
if (!sniffed || sniffed !== file.mimetype) {
|
||||
await discard()
|
||||
return { ok: false, status: 400, error: 'That file is not the image type it claims to be' }
|
||||
}
|
||||
|
||||
const used = await forumDb.bytesUploadedSince(actor.id, QUOTA_WINDOW_HOURS)
|
||||
if (used + file.size > DAILY_QUOTA_BYTES) {
|
||||
await discard()
|
||||
return { ok: false, status: 429, error: 'Daily upload limit reached. Try again tomorrow.' }
|
||||
}
|
||||
|
||||
const id = await forumDb.insertUpload({
|
||||
teamId: team.id,
|
||||
postId: null, // attached when the post that embeds it is written
|
||||
uploaderUserId: actor.id,
|
||||
uploaderUsername: actor.username,
|
||||
filename: file.filename,
|
||||
mimetype: sniffed, // the SNIFFED type, never the client's header
|
||||
byteSize: file.size,
|
||||
})
|
||||
return { ok: true, id, url: `/uploads/${file.filename}`, bytes: file.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an upload. The uploader may, within the edit window; staff may at any
|
||||
* time. Soft — the bytes go with the sweep, not with the button.
|
||||
*/
|
||||
async function remove({ id, actor, isStaff }) {
|
||||
const row = await forumDb.uploadById(id)
|
||||
if (!row || row.deleted_at) return { ok: false, status: 404, error: 'No such upload' }
|
||||
if (!isStaff && row.uploader_user_id !== actor.id) {
|
||||
return { ok: false, status: 403, error: 'Not your upload' }
|
||||
}
|
||||
await forumDb.softDeleteUpload(id, actor.id)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The nightly sweep: bytes for soft-deleted rows past retention, plus files on
|
||||
* disk with no row at all.
|
||||
*
|
||||
* The orphan half deliberately only considers files whose names match the upload
|
||||
* naming scheme AND appear in no row. UPLOAD_DIR is shared with the admin upload
|
||||
* path, whose files have no row here and must never be swept — so the sweep works
|
||||
* from the FORUM's own rows outward and never from the directory listing inward.
|
||||
*/
|
||||
async function sweep({ retentionDays = RETENTION_DAYS, orphanGraceHours = ORPHAN_GRACE_HOURS } = {}) {
|
||||
const expired = await forumDb.sweepableUploads(retentionDays)
|
||||
const orphans = await forumDb.orphanedUploads(orphanGraceHours)
|
||||
const doomed = [...expired, ...orphans]
|
||||
const cleared = []
|
||||
for (const row of doomed) {
|
||||
try {
|
||||
await fs.rm(path.join(UPLOAD_DIR, row.filename), { force: true })
|
||||
cleared.push(row.id)
|
||||
} catch {
|
||||
// Leave the ROW as well as the file. A file we could not delete is one the
|
||||
// next run should try again, and dropping its row would lose the only
|
||||
// record that the bytes are still there.
|
||||
}
|
||||
}
|
||||
await forumDb.deleteUploadRows(cleared)
|
||||
return { swept: doomed.length, filesRemoved: cleared.length }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_ATTACHMENTS_PER_POST,
|
||||
DAILY_QUOTA_BYTES,
|
||||
QUOTA_WINDOW_HOURS,
|
||||
RETENTION_DAYS,
|
||||
ORPHAN_GRACE_HOURS,
|
||||
sniff,
|
||||
accept,
|
||||
remove,
|
||||
sweep,
|
||||
}
|
||||
169
server/src/model/teams/teamGrants.model.js
Normal file
169
server/src/model/teams/teamGrants.model.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// ── The grant/revoke flow (TEAMS.md §2.5 path 3) ───────────────────────────
|
||||
//
|
||||
// The RESOLVER lives in teamAccess.model.js and answers "may this account use the
|
||||
// forum". This file is the WRITE half: who may hand that access out, to whom, and
|
||||
// what stops a leader turning a Team forum into open hosting on the operator's
|
||||
// site.
|
||||
//
|
||||
// **Two authorities, and they are not the same authority with different reach.**
|
||||
//
|
||||
// staff (admin | moderator) — any Team, no cap, may revoke anything
|
||||
// leader (path 2, on THIS Team) — own Team, capped, may not revoke a staff grant
|
||||
//
|
||||
// The last clause is the one worth stating: a leader who could revoke a
|
||||
// staff-issued grant could undo a moderation decision, which is the whole reason
|
||||
// `granted_by` is retained rather than collapsed into a boolean.
|
||||
//
|
||||
// **Nothing here writes `team_members`, in either direction, ever.** A grant is
|
||||
// not a membership: it may name any Runic Gateway account, including one with no
|
||||
// linked game identity at all — that is the point of it, since letting an unlinked
|
||||
// guildmate into the forum must not be a staff ticket. `teams.model.js` keeps such
|
||||
// an account off the roster and out of every membership count, and path 4 keeps it
|
||||
// off external platforms.
|
||||
|
||||
const accessDb = require('./teamAccess.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const access = require('./teamAccess.model')
|
||||
const usersDb = require('../users/users.db')
|
||||
const settingsDb = require('../settings/settings.db')
|
||||
|
||||
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
|
||||
// unlimited arbitrary accounts to a private space on the operator's host is a
|
||||
// quiet way to turn a Team forum into free hosting; the cap is what makes it a
|
||||
// decision the operator made rather than one a leader made for them.
|
||||
const CAP_KEY = 'teams_max_grants_per_team'
|
||||
const DEFAULT_CAP = 50
|
||||
|
||||
const STAFF_ROLES = ['admin', 'moderator']
|
||||
|
||||
async function grantCap() {
|
||||
const raw = await settingsDb.get(CAP_KEY)
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CAP
|
||||
}
|
||||
|
||||
const isStaff = (actor) => STAFF_ROLES.includes(actor?.role)
|
||||
|
||||
/**
|
||||
* What may this actor do with grants on this Team?
|
||||
*
|
||||
* Resolved once and returned whole, so the controller asks a question rather than
|
||||
* assembling the answer from three booleans — the shape that lets a leader check
|
||||
* and a staff check drift apart.
|
||||
*/
|
||||
async function authorityFor(teamId, actor) {
|
||||
if (isStaff(actor)) return { may: true, as: 'staff' }
|
||||
const leads = await access.isLeaderByUser(teamId, actor?.id)
|
||||
return { may: leads, as: leads ? 'leader' : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a grant. Returns the model result shape the Teams controllers translate:
|
||||
* `{ ok }` or `{ ok: false, status, error }`.
|
||||
*
|
||||
* `warning` on a staff grant past the cap is deliberate and is not an error:
|
||||
* staff are exempt, and silently exceeding a ceiling the operator configured is
|
||||
* worth saying out loud on the way past.
|
||||
*/
|
||||
async function grant({ team, actor, userId, username, reason }) {
|
||||
const authority = await authorityFor(team.id, actor)
|
||||
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
|
||||
|
||||
const target = userId
|
||||
? await usersDb.findById(userId)
|
||||
: await usersDb.findByUsername(username)
|
||||
if (!target) return { ok: false, status: 404, error: 'No such account' }
|
||||
|
||||
const existing = await accessDb.activeGrant(team.id, target.id)
|
||||
if (existing) return { ok: false, status: 409, error: 'That account already has an active grant' }
|
||||
|
||||
const cap = await grantCap()
|
||||
const count = await accessDb.activeGrantCount(team.id)
|
||||
let warning = null
|
||||
if (count >= cap) {
|
||||
if (authority.as === 'leader') {
|
||||
return { ok: false, status: 409, error: `This Team has reached its limit of ${cap} forum guests` }
|
||||
}
|
||||
warning = `This Team is past the configured limit of ${cap} forum guests`
|
||||
}
|
||||
|
||||
await accessDb.insertGrant({
|
||||
teamId: team.id,
|
||||
userId: target.id,
|
||||
username: target.username,
|
||||
grantedBy: actor.id,
|
||||
grantedUsername: actor.username,
|
||||
reason,
|
||||
})
|
||||
return { ok: true, as: authority.as, grantee: target.username, ...(warning ? { warning } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a grant.
|
||||
*
|
||||
* The one asymmetry with `grant`: a leader may not revoke what staff issued.
|
||||
* Checked against `granted_by`'s role AT REVOKE TIME rather than against a stored
|
||||
* flag, so an account that has since lost its staff role stops protecting the
|
||||
* grants it made — which is the behaviour an operator demoting someone expects.
|
||||
*/
|
||||
async function revoke({ team, actor, userId, reason }) {
|
||||
const authority = await authorityFor(team.id, actor)
|
||||
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
|
||||
|
||||
const existing = await accessDb.activeGrant(team.id, userId)
|
||||
if (!existing) return { ok: false, status: 404, error: 'No active grant for that account' }
|
||||
|
||||
if (authority.as === 'leader' && existing.granted_by) {
|
||||
const issuer = await usersDb.findById(existing.granted_by)
|
||||
if (isStaff(issuer)) {
|
||||
return { ok: false, status: 403, error: 'That access was granted by staff and only staff may revoke it' }
|
||||
}
|
||||
}
|
||||
|
||||
await accessDb.revokeGrant({
|
||||
teamId: team.id,
|
||||
userId,
|
||||
revokedBy: actor.id,
|
||||
revokedUsername: actor.username,
|
||||
reason,
|
||||
})
|
||||
return { ok: true, as: authority.as, grantee: existing.username }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Team's forum guests — active grants for accounts that are NOT members.
|
||||
*
|
||||
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
|
||||
* and a grantee is a member, listed on the roster, and appears here not at all.
|
||||
* Both facts stay true in the ledger; only the presentation picks one.
|
||||
*/
|
||||
async function forumGuests(teamId) {
|
||||
const [grants, members] = await Promise.all([
|
||||
accessDb.activeGrants(teamId),
|
||||
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
|
||||
])
|
||||
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
|
||||
return grants
|
||||
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
|
||||
.map((g) => ({
|
||||
userId: g.user_id,
|
||||
username: g.username,
|
||||
grantedBy: g.granted_username,
|
||||
grantedAt: g.granted_at,
|
||||
reason: g.reason,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CAP_KEY,
|
||||
DEFAULT_CAP,
|
||||
// Exported since phase 7: the Discord dispatcher's `access: 'staff'` has to
|
||||
// mean the same two roles every other Team surface means by it, and a second
|
||||
// copy of the list is a copy that drifts.
|
||||
STAFF_ROLES,
|
||||
grantCap,
|
||||
authorityFor,
|
||||
grant,
|
||||
revoke,
|
||||
forumGuests,
|
||||
}
|
||||
122
server/src/model/teams/teamIntegration.db.js
Normal file
122
server/src/model/teams/teamIntegration.db.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// SQL for the integration bridge's configuration (TEAMS.md §7.2, phase 8).
|
||||
//
|
||||
// One table, and almost all of its subtlety is in the schema comment rather than
|
||||
// here: `team_key` is a generated `IFNULL(team_id, 0)`, so the deployment-wide
|
||||
// default and the per-Team overrides live under one UNIQUE key without the
|
||||
// default row needing a NULL in a primary key it cannot have.
|
||||
//
|
||||
// **Reads join `teams` and callers get the Team's name.** Not for display alone:
|
||||
// the resolver's answer is the input to a message that names a Team, and a second
|
||||
// round trip per notification to fetch a name the first query already walked past
|
||||
// is the kind of thing that only shows up under a busy forum.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLUMNS = `
|
||||
c.id, c.platform, c.team_id, c.events, c.channel_ref, c.enabled,
|
||||
c.members_ack, c.members_ack_by, c.members_ack_at, c.updated_at`
|
||||
|
||||
/**
|
||||
* Every row for a platform — the default first, then the overrides by Team name.
|
||||
*
|
||||
* The admin panel's whole listing, in one query. `team_name` is NULL on exactly
|
||||
* one row (the default), which is also how the client tells them apart without
|
||||
* needing to reason about `team_id`.
|
||||
*/
|
||||
async function listForPlatform(platform) {
|
||||
return query(
|
||||
`SELECT ${COLUMNS}, t.name AS team_name, t.slug AS team_slug, t.display_name_override,
|
||||
u.username AS members_ack_username
|
||||
FROM team_integration_config c
|
||||
LEFT JOIN teams t ON t.id = c.team_id
|
||||
LEFT JOIN users u ON u.id = c.members_ack_by
|
||||
WHERE c.platform = ?
|
||||
ORDER BY c.team_id IS NOT NULL, COALESCE(t.name, '')`,
|
||||
[platform],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row that governs `teamId`, or null.
|
||||
*
|
||||
* `team_key` is what makes this one query rather than two: asking for the pair
|
||||
* (0, teamId) returns the default and the override together, and `ORDER BY
|
||||
* team_key DESC LIMIT 1` puts the override first when it exists. A caller that
|
||||
* fetched the default and then looked for an override would do two round trips
|
||||
* per notification for an answer the index already holds.
|
||||
*/
|
||||
async function resolveFor(platform, teamId) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS}, t.name AS team_name, t.display_name_override
|
||||
FROM team_integration_config c
|
||||
LEFT JOIN teams t ON t.id = c.team_id
|
||||
WHERE c.platform = ? AND c.team_key IN (0, ?)
|
||||
ORDER BY c.team_key DESC
|
||||
LIMIT 1`,
|
||||
[platform, Number(teamId)],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS}, t.name AS team_name FROM team_integration_config c
|
||||
LEFT JOIN teams t ON t.id = c.team_id
|
||||
WHERE c.id = ? LIMIT 1`,
|
||||
[Number(id)],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function getForTeam(platform, teamId) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM team_integration_config c
|
||||
WHERE c.platform = ? AND c.team_key = ? LIMIT 1`,
|
||||
[platform, teamId === null || teamId === undefined ? 0 : Number(teamId)],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace the row for (platform, team).
|
||||
*
|
||||
* A full replace rather than a patch, and the acknowledgement columns are part of
|
||||
* what is replaced — the model decides what they should be, because "did the
|
||||
* channel change" is a comparison against the row that is about to be overwritten
|
||||
* and only the model has both halves.
|
||||
*/
|
||||
async function upsert({ platform, teamId, events, channelRef, enabled, membersAck, membersAckBy, membersAckAt }) {
|
||||
await query(
|
||||
`INSERT INTO team_integration_config
|
||||
(platform, team_id, events, channel_ref, enabled, members_ack, members_ack_by, members_ack_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
events = VALUES(events),
|
||||
channel_ref = VALUES(channel_ref),
|
||||
enabled = VALUES(enabled),
|
||||
members_ack = VALUES(members_ack),
|
||||
members_ack_by = VALUES(members_ack_by),
|
||||
members_ack_at = VALUES(members_ack_at)`,
|
||||
[
|
||||
platform,
|
||||
teamId === null || teamId === undefined ? null : Number(teamId),
|
||||
JSON.stringify(events || []),
|
||||
channelRef || null,
|
||||
enabled ? 1 : 0,
|
||||
membersAck ? 1 : 0,
|
||||
membersAckBy || null,
|
||||
membersAckAt || null,
|
||||
],
|
||||
)
|
||||
return getForTeam(platform, teamId)
|
||||
}
|
||||
|
||||
async function remove(platform, teamId) {
|
||||
const res = await query('DELETE FROM team_integration_config WHERE platform = ? AND team_key = ?', [
|
||||
platform,
|
||||
teamId === null || teamId === undefined ? 0 : Number(teamId),
|
||||
])
|
||||
return Number(res && res.affectedRows) || 0
|
||||
}
|
||||
|
||||
module.exports = { listForPlatform, resolveFor, getById, getForTeam, upsert, remove }
|
||||
272
server/src/model/teams/teamIntegration.model.js
Normal file
272
server/src/model/teams/teamIntegration.model.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// ── The integration bridge's configuration and its one precondition ────────
|
||||
//
|
||||
// TEAMS.md §7.2, phase 8. An operator says "send these Team events to this
|
||||
// Discord channel", globally or for one Team, and this file is where that
|
||||
// sentence is validated, stored and resolved.
|
||||
//
|
||||
// **Two of the four streams can never be public, and that is the whole reason
|
||||
// this file is more than a settings row.** §7.2 gates bridging on "the event's
|
||||
// visibility is public, or the destination channel is configured for a
|
||||
// members-only Team context". Neither half exists in the tree and neither can:
|
||||
// the four `team.*` streams carry no visibility (only `team_activity` rows do,
|
||||
// and a notification is not an activity row), forum threads have no public/
|
||||
// members column because a forum is members-only by construction — everything in
|
||||
// it sits behind `team_forum_grants` — and core cannot see a Discord channel's
|
||||
// permissions to know what it is.
|
||||
//
|
||||
// Only the operator can see that. So the gate becomes an ATTRIBUTED
|
||||
// ACKNOWLEDGEMENT: enabling a members-only event requires an explicit tick that
|
||||
// the destination is restricted to that Team's members, recorded with who gave it
|
||||
// and when, in the same shape `teams_forum_uploads_ack` records the image-policy
|
||||
// one. It is a precondition, not a preference — `assertEnableable` refuses the
|
||||
// save rather than quietly dropping the event at delivery time, because a config
|
||||
// that silently does less than it says is worse than one that will not save.
|
||||
//
|
||||
// **Changing the channel clears the acknowledgement.** An acknowledgement is
|
||||
// about a destination; it cannot survive the destination changing underneath it,
|
||||
// or an operator would tick "this channel is private", then repoint the row at a
|
||||
// public one and keep the permission they were granted for a different place.
|
||||
//
|
||||
// **Every read fails closed**, like `teamForumSettings`: a DB fault reports no
|
||||
// bridge configured, because the cost of failing closed is a Discord channel that
|
||||
// stays quiet for a minute and the cost of failing open is members-only text in a
|
||||
// room the operator never approved.
|
||||
|
||||
const db = require('./teamIntegration.db')
|
||||
const log = require('../../utils/logger')('team-integration')
|
||||
|
||||
// The only platform phase 8 knows. Deliberately a value rather than a hardcoded
|
||||
// literal at every call site: phase 10 turns this into a lookup against the
|
||||
// declared-capability registry, and the fewer places that spell 'discord' the
|
||||
// smaller that change is.
|
||||
const DISCORD = 'discord'
|
||||
const PLATFORMS = [DISCORD]
|
||||
|
||||
// The four §6.2 streams, and which of them can reach a channel core cannot vet.
|
||||
//
|
||||
// A stream is members-only if the CONTENT behind it is: `team.forum.post` and
|
||||
// `team.announcement` both name a thread nobody outside the Team may read. The
|
||||
// roster pair is public — Team pages and rosters are public by §1's projection
|
||||
// rules — so bridging those asserts nothing and needs no tick.
|
||||
const BRIDGEABLE = [
|
||||
'team.member.joined',
|
||||
'team.leadership.changed',
|
||||
'team.forum.post',
|
||||
'team.announcement',
|
||||
]
|
||||
|
||||
const MEMBERS_ONLY = new Set(['team.forum.post', 'team.announcement'])
|
||||
|
||||
// Discord snowflakes are 17-20 digits today and the format is not promised. The
|
||||
// check is only that a channel ref is plausibly one and cannot smuggle anything —
|
||||
// core treats it as opaque and the bot is what resolves it.
|
||||
const CHANNEL_RE = /^[0-9]{5,32}$/
|
||||
|
||||
const isMembersOnly = (streamId) => MEMBERS_ONLY.has(streamId)
|
||||
|
||||
/** Does this event list contain anything that would publish members-only text? */
|
||||
const needsAck = (events) => (events || []).some(isMembersOnly)
|
||||
|
||||
/**
|
||||
* Normalise an operator-supplied event list.
|
||||
*
|
||||
* Unknown ids are REJECTED rather than dropped. A silently-dropped event is a
|
||||
* config screen that shows you saved something you did not, and the set is small
|
||||
* and fixed enough that a typo is a mistake worth reporting.
|
||||
*/
|
||||
function normaliseEvents(events) {
|
||||
if (!Array.isArray(events)) {
|
||||
const err = new Error('events must be an array')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
const seen = []
|
||||
for (const raw of events) {
|
||||
const id = String(raw || '').trim()
|
||||
if (!BRIDGEABLE.includes(id)) {
|
||||
const err = new Error(`unknown event: ${id}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (!seen.includes(id)) seen.push(id)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
function normaliseChannel(channelRef) {
|
||||
const value = String(channelRef || '').trim()
|
||||
if (!value) return null
|
||||
if (!CHANNEL_RE.test(value)) {
|
||||
const err = new Error('channel must be a numeric channel id')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate, as a throw.
|
||||
*
|
||||
* Order matters to the message an operator reads: an enabled row with no channel
|
||||
* is a different mistake from one with an unacknowledged channel, and reporting
|
||||
* the second when the first is true would send them to tick a box that would not
|
||||
* have helped.
|
||||
*/
|
||||
function assertEnableable({ enabled, events, channelRef, membersAck }) {
|
||||
if (!enabled) return
|
||||
if (!channelRef) {
|
||||
const err = new Error('a destination channel is required to enable this bridge')
|
||||
err.status = 422
|
||||
throw err
|
||||
}
|
||||
if (events.length === 0) {
|
||||
const err = new Error('at least one event is required to enable this bridge')
|
||||
err.status = 422
|
||||
throw err
|
||||
}
|
||||
if (needsAck(events) && !membersAck) {
|
||||
const err = new Error(
|
||||
'forum posts and announcements are visible only to a Team’s members — confirm the destination channel is restricted to them before enabling',
|
||||
)
|
||||
err.status = 422
|
||||
err.code = 'members_ack_required'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows for the admin panel, `events` already parsed. */
|
||||
async function list(platform = DISCORD) {
|
||||
const rows = await db.listForPlatform(platform)
|
||||
return rows.map(shape)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the stored JSON once, here.
|
||||
*
|
||||
* `mariadb` hands a JSON column back as a string on some server versions and as a
|
||||
* parsed value on others, which is a difference nobody wants to rediscover in a
|
||||
* controller. Anything unreadable becomes an empty list rather than a throw: a
|
||||
* row with a corrupt event list should render as a row that bridges nothing, not
|
||||
* take the whole admin page down.
|
||||
*/
|
||||
function shape(row) {
|
||||
if (!row) return null
|
||||
let events = row.events
|
||||
if (typeof events === 'string') {
|
||||
try {
|
||||
events = JSON.parse(events)
|
||||
} catch {
|
||||
events = []
|
||||
}
|
||||
}
|
||||
return { ...row, events: Array.isArray(events) ? events : [], enabled: !!row.enabled, members_ack: !!row.members_ack }
|
||||
}
|
||||
|
||||
/**
|
||||
* The row that governs `teamId` — the override if there is one, otherwise the
|
||||
* deployment default — filtered down to what may actually be delivered.
|
||||
*
|
||||
* **The acknowledgement is checked HERE as well as at the save.** A row saved
|
||||
* with the tick can lose it later: an admin repoints the channel, or a future
|
||||
* change to what counts as members-only reclassifies a stream a row already
|
||||
* carries. Re-asking at delivery is what makes the tick a live property of the
|
||||
* row rather than a note about a save that happened once.
|
||||
*/
|
||||
async function resolve(teamId, platform = DISCORD) {
|
||||
try {
|
||||
const row = shape(await db.resolveFor(platform, teamId))
|
||||
if (!row || !row.enabled || !row.channel_ref) return null
|
||||
const events = row.events.filter((id) => (isMembersOnly(id) ? row.members_ack : true))
|
||||
if (events.length === 0) return null
|
||||
return { ...row, events }
|
||||
} catch (err) {
|
||||
log.warn('bridge config lookup failed — treating as unconfigured', {
|
||||
teamId,
|
||||
platform,
|
||||
message: err.message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Is `streamId` bridged for this Team? The delivery path's whole question. */
|
||||
async function destinationFor(teamId, streamId, platform = DISCORD) {
|
||||
const row = await resolve(teamId, platform)
|
||||
if (!row || !row.events.includes(streamId)) return null
|
||||
return { channelRef: row.channel_ref, membersOnly: isMembersOnly(streamId), platform }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace the row for (platform, team).
|
||||
*
|
||||
* `actorId` is the admin doing the saving, and it is what lands in
|
||||
* `members_ack_by` — the acknowledgement names a person, so it cannot be written
|
||||
* by a path that does not know who they are.
|
||||
*/
|
||||
async function save({ platform = DISCORD, teamId = null, events, channelRef, enabled, membersAck }, actorId) {
|
||||
if (!PLATFORMS.includes(platform)) {
|
||||
const err = new Error(`unknown platform: ${platform}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
|
||||
const nextEvents = normaliseEvents(events)
|
||||
const nextChannel = normaliseChannel(channelRef)
|
||||
const existing = shape(await db.getForTeam(platform, teamId))
|
||||
|
||||
// An acknowledgement survives an ordinary edit and dies with the channel it was
|
||||
// given for. `membersAck === false` from the client is an explicit withdrawal
|
||||
// and is honoured; `undefined` means "leave it", which is what a save that only
|
||||
// toggled an event should do.
|
||||
const channelChanged = !!existing && existing.channel_ref !== nextChannel
|
||||
let ack = existing ? existing.members_ack : false
|
||||
if (membersAck === false) ack = false
|
||||
else if (membersAck === true) ack = true
|
||||
if (channelChanged) ack = membersAck === true
|
||||
|
||||
const nextEnabled = !!enabled
|
||||
assertEnableable({ enabled: nextEnabled, events: nextEvents, channelRef: nextChannel, membersAck: ack })
|
||||
|
||||
// Re-stamp only when the acknowledgement is newly given, so an unrelated save
|
||||
// does not rewrite the date on a decision nobody revisited.
|
||||
//
|
||||
// `channelChanged` belongs in this condition and it is easy to leave out: an
|
||||
// acknowledgement given alongside a NEW channel is a new acknowledgement even
|
||||
// though the column was already 1, and without it the row keeps naming whoever
|
||||
// vetted the PREVIOUS destination. That attribution is the whole audit value of
|
||||
// the column — it has to name the person who looked at the channel the row now
|
||||
// points at.
|
||||
const freshlyAcked = ack && (channelChanged || !(existing && existing.members_ack))
|
||||
const row = await db.upsert({
|
||||
platform,
|
||||
teamId,
|
||||
events: nextEvents,
|
||||
channelRef: nextChannel,
|
||||
enabled: nextEnabled,
|
||||
membersAck: ack,
|
||||
membersAckBy: ack ? (freshlyAcked ? actorId : existing.members_ack_by) : null,
|
||||
membersAckAt: ack ? (freshlyAcked ? new Date() : existing.members_ack_at) : null,
|
||||
})
|
||||
return shape(row)
|
||||
}
|
||||
|
||||
async function remove(platform, teamId) {
|
||||
return db.remove(platform, teamId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DISCORD,
|
||||
PLATFORMS,
|
||||
BRIDGEABLE,
|
||||
MEMBERS_ONLY,
|
||||
isMembersOnly,
|
||||
needsAck,
|
||||
normaliseEvents,
|
||||
normaliseChannel,
|
||||
assertEnableable,
|
||||
list,
|
||||
resolve,
|
||||
destinationFor,
|
||||
save,
|
||||
remove,
|
||||
}
|
||||
123
server/src/model/teams/teamModeration.db.js
Normal file
123
server/src/model/teams/teamModeration.db.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// SQL for the reserved-name review queue and the §2.9 approval queue.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── The hide/display state on `teams` ──────────────────────────────────────
|
||||
|
||||
async function setHidden(teamId, { hidden, reason, term }) {
|
||||
await query(
|
||||
'UPDATE teams SET hidden = ?, hidden_reason = ?, hidden_term = ? WHERE id = ?',
|
||||
[hidden ? 1 : 0, hidden ? reason : null, hidden ? term || null : null, teamId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a human has decided about this name.
|
||||
*
|
||||
* What makes a staff decision STICKY (§2.8.3). Re-screening runs on every sync,
|
||||
* and without this stamp an operator adding a reserved term — or simply renaming
|
||||
* the deployment — would re-hide a Team staff had already allowed, every fifteen
|
||||
* minutes, forever.
|
||||
*/
|
||||
async function markNameReviewed(teamId) {
|
||||
await query('UPDATE teams SET name_reviewed_at = NOW() WHERE id = ?', [teamId])
|
||||
}
|
||||
|
||||
async function setDisplayNameOverride(teamId, displayName) {
|
||||
await query('UPDATE teams SET display_name_override = ? WHERE id = ?', [displayName, teamId])
|
||||
}
|
||||
|
||||
/** Active teams whose name has never been screened by a human. */
|
||||
async function unreviewedActive(moduleId) {
|
||||
return query(
|
||||
`SELECT id, name, hidden, hidden_reason FROM teams
|
||||
WHERE module_id = ? AND status = 'active' AND name_reviewed_at IS NULL`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
/** The reserved-name review queue (§2.8.3). */
|
||||
async function reviewQueue() {
|
||||
return query(
|
||||
`SELECT id, name, slug, hidden_term, display_name_override, member_count, created_at
|
||||
FROM teams
|
||||
WHERE status = 'active' AND hidden = 1 AND hidden_reason = 'reserved_name' AND name_reviewed_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── team_moderation_requests (§2.9) ────────────────────────────────────────
|
||||
|
||||
const REQUEST_COLUMNS = `
|
||||
id, team_id, action, payload, reason, requested_by, requested_username, requested_at,
|
||||
status, decided_by, decided_username, decided_at, decision_note`
|
||||
|
||||
async function insertRequest({ teamId, action, payload, reason, requestedBy, requestedUsername }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_moderation_requests
|
||||
(team_id, action, payload, reason, requested_by, requested_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[teamId, action, payload == null ? null : JSON.stringify(payload), reason, requestedBy, requestedUsername],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findRequest(id) {
|
||||
const rows = await query(`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests WHERE id = ?`, [id])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** The approval queue. Decided rows are kept — see §2.9 — so `status` is a filter. */
|
||||
async function listRequests({ status = 'pending', limit = 100 } = {}) {
|
||||
const params = []
|
||||
let sql = `SELECT r.${REQUEST_COLUMNS.trim().split(/,\s*/).join(', r.')},
|
||||
t.name AS team_name, t.slug AS team_slug
|
||||
FROM team_moderation_requests r JOIN teams t ON t.id = r.team_id`
|
||||
if (status !== 'all') {
|
||||
sql += ' WHERE r.status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
sql += ' ORDER BY r.requested_at DESC, r.id DESC LIMIT ?'
|
||||
params.push(limit)
|
||||
return query(sql, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a request, but only if it is still pending.
|
||||
*
|
||||
* The `status = 'pending'` guard is the concurrency control: two admins opening
|
||||
* the same queue and both clicking approve would otherwise each apply the action,
|
||||
* and the second would overwrite the first's record of who decided it. The caller
|
||||
* applies the effect only when this reports a row was actually moved.
|
||||
*/
|
||||
async function decideRequest(id, { status, decidedBy, decidedUsername, note }) {
|
||||
const res = await query(
|
||||
`UPDATE team_moderation_requests
|
||||
SET status = ?, decided_by = ?, decided_username = ?, decided_at = NOW(), decision_note = ?
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[status, decidedBy, decidedUsername, note, id],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
/** Pending requests for one team — shown on its admin page so a second is not filed. */
|
||||
async function pendingForTeam(teamId) {
|
||||
return query(
|
||||
`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests
|
||||
WHERE team_id = ? AND status = 'pending' ORDER BY requested_at`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setHidden,
|
||||
markNameReviewed,
|
||||
setDisplayNameOverride,
|
||||
unreviewedActive,
|
||||
reviewQueue,
|
||||
insertRequest,
|
||||
findRequest,
|
||||
listRequests,
|
||||
decideRequest,
|
||||
pendingForTeam,
|
||||
}
|
||||
239
server/src/model/teams/teamModeration.model.js
Normal file
239
server/src/model/teams/teamModeration.model.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// ── Impersonation controls, and the approval gate on them ──────────────────
|
||||
//
|
||||
// TEAMS.md §2.8–§2.9. Two things live here:
|
||||
//
|
||||
// 1. **Auto-hide**, which turns a reserved-name match into a suppressed Team
|
||||
// and a review queue entry rather than into a refusal. Core cannot refuse a
|
||||
// name — the guild exists in the game and core is a mirror of it.
|
||||
//
|
||||
// 2. **The approval gate**, which is scoped to the three actions that RELEASE
|
||||
// untrusted game-sourced strings onto public surfaces, and to nothing else.
|
||||
//
|
||||
// **The gate's scope is the part most likely to be misread.** It is not a general
|
||||
// staff-approval workflow. Ordinary forum grants, leadership overrides, archives
|
||||
// and forum moderation all still apply immediately and are audited, exactly as
|
||||
// before. Three actions are gated, and the question that admits a fourth is
|
||||
// always the same one: *does this publish untrusted game data?*
|
||||
//
|
||||
// - clearing a reserved_name hide — publishes a name that tripped the list
|
||||
// - setting a display_name_override — substitutes free text into the same
|
||||
// public surfaces
|
||||
// - un-hiding a staff-hidden Team — reverses a deliberate suppression
|
||||
//
|
||||
// **Moderator-initiated, admin-approved — never four-eyes on admins.** `users.role`
|
||||
// defaults to admin and `npm run seed` creates exactly one, so most deployments
|
||||
// have precisely one admin. A rule requiring a second would wedge them with no
|
||||
// way out, which is a worse failure than the one it guards against.
|
||||
|
||||
const moderationDb = require('./teamModeration.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const reservedNames = require('../../utils/reservedNames')
|
||||
const activity = require('../activity/activity.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
const GATED_ACTIONS = ['unhide', 'display_name_override', 'clear_display_name_override']
|
||||
|
||||
const isAdmin = (actor) => Boolean(actor) && actor.role === 'admin'
|
||||
|
||||
/**
|
||||
* Screen a name and return the columns a create should carry.
|
||||
*
|
||||
* Never throws: screening reads settings, and a database hiccup during a
|
||||
* reconcile must not stop a Team being created. It fails OPEN on the create — the
|
||||
* Team appears — because the re-screen on the next sync will catch it, and a
|
||||
* reconcile that aborts halfway is worse than a name that is public for one
|
||||
* interval. That is a deliberate trade and it is the reason re-screening exists
|
||||
* at all rather than being a create-time-only check.
|
||||
*/
|
||||
async function screenForCreate(name) {
|
||||
try {
|
||||
const { reserved, term } = await reservedNames.screen(name)
|
||||
if (!reserved) return { hidden: false }
|
||||
log.warn('team auto-hidden: its name matched a reserved term', { name, term })
|
||||
return { hidden: true, hiddenReason: 'reserved_name', hiddenTerm: term }
|
||||
} catch (err) {
|
||||
log.error('reserved-name screening failed; the team is created unscreened', {
|
||||
name, message: err.message,
|
||||
})
|
||||
return { hidden: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-screen every active Team whose name no human has ruled on.
|
||||
*
|
||||
* Names are immutable per row, so this only ever changes an outcome when the TERM
|
||||
* LIST changed — an operator adding a term, or the deployment being renamed. That
|
||||
* is precisely the case a create-time-only check would miss forever.
|
||||
*
|
||||
* A Team staff have already decided about is skipped, and that stickiness is the
|
||||
* point: without it, an override would be undone on the next sweep.
|
||||
*/
|
||||
async function rescreen(moduleId) {
|
||||
let hidden = 0
|
||||
try {
|
||||
const rows = await moderationDb.unreviewedActive(moduleId)
|
||||
for (const row of rows) {
|
||||
if (row.hidden) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { reserved, term } = await reservedNames.screen(row.name)
|
||||
if (!reserved) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await moderationDb.setHidden(row.id, { hidden: true, reason: 'reserved_name', term })
|
||||
hidden += 1
|
||||
log.warn('team hidden by a re-screen: the reserved terms changed', { id: row.id, name: row.name, term })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('re-screening failed', { message: err.message })
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
// ── The three gated actions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a gated action, or file it for approval.
|
||||
*
|
||||
* The role check is answered LIVE against the database on every request by core's
|
||||
* admin middleware, so "is this caller an admin" is not read from a token claim
|
||||
* that a demotion would not have invalidated.
|
||||
*/
|
||||
async function requestOrApply({ req, actor, teamId, action, payload, reason }) {
|
||||
if (!GATED_ACTIONS.includes(action)) throw new Error(`not a gated action: "${action}"`)
|
||||
const team = await teamsDb.findById(teamId)
|
||||
if (!team) return { ok: false, status: 404, error: 'team not found' }
|
||||
|
||||
if (!isAdmin(actor)) {
|
||||
const id = await moderationDb.insertRequest({
|
||||
teamId, action, payload, reason, requestedBy: actor.id, requestedUsername: actor.username,
|
||||
})
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.moderation.request',
|
||||
detail: `${actor.username} (#${actor.id}) requested "${action}" on team "${team.name}" (#${teamId})`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
return { ok: true, pending: true, requestId: id }
|
||||
}
|
||||
|
||||
await applyAction({ req, actor, team, action, payload, reason })
|
||||
return { ok: true, pending: false }
|
||||
}
|
||||
|
||||
/** The effect itself. Reached by an admin directly, or by an approval. */
|
||||
async function applyAction({ req, actor, team, action, payload, reason }) {
|
||||
switch (action) {
|
||||
case 'unhide':
|
||||
await moderationDb.setHidden(team.id, { hidden: false })
|
||||
// A human has now ruled on this name, so no later sweep re-hides it.
|
||||
await moderationDb.markNameReviewed(team.id)
|
||||
break
|
||||
case 'display_name_override':
|
||||
await moderationDb.setDisplayNameOverride(team.id, payload.displayName)
|
||||
await moderationDb.markNameReviewed(team.id)
|
||||
break
|
||||
case 'clear_display_name_override':
|
||||
await moderationDb.setDisplayNameOverride(team.id, null)
|
||||
break
|
||||
default:
|
||||
throw new Error(`not a gated action: "${action}"`)
|
||||
}
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: `team.${action}`,
|
||||
detail: `${actor.username} (#${actor.id}) applied "${action}" to team "${team.name}" (#${team.id})`
|
||||
+ `${payload && payload.displayName ? ` as "${payload.displayName}"` : ''}`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a Team. NOT gated — suppression is always safe (§2.11).
|
||||
*
|
||||
* The asymmetry is the whole design: publishing untrusted data needs a second
|
||||
* pair of eyes, and withdrawing it needs to be possible at once, by whoever is
|
||||
* on duty.
|
||||
*/
|
||||
async function hide({ req, actor, teamId, reason }) {
|
||||
const team = await teamsDb.findById(teamId)
|
||||
if (!team) return { ok: false, status: 404, error: 'team not found' }
|
||||
await moderationDb.setHidden(teamId, { hidden: true, reason: 'staff' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.hide',
|
||||
detail: `${actor.username} (#${actor.id}) hid team "${team.name}" (#${teamId})`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Admin only.
|
||||
*
|
||||
* The effect is applied only when the row actually moved out of `pending`, so two
|
||||
* admins deciding the same request race safely: the second is told it was already
|
||||
* decided rather than applying the action a second time.
|
||||
*/
|
||||
async function decide({ req, actor, requestId, status, note }) {
|
||||
if (!isAdmin(actor)) return { ok: false, status: 403, error: 'only an admin may decide a request' }
|
||||
if (!['approved', 'rejected'].includes(status)) {
|
||||
return { ok: false, status: 400, error: 'status must be approved or rejected' }
|
||||
}
|
||||
|
||||
const request = await moderationDb.findRequest(requestId)
|
||||
if (!request) return { ok: false, status: 404, error: 'request not found' }
|
||||
if (request.status !== 'pending') {
|
||||
return { ok: false, status: 409, error: `request is already ${request.status}` }
|
||||
}
|
||||
|
||||
const moved = await moderationDb.decideRequest(requestId, {
|
||||
status, decidedBy: actor.id, decidedUsername: actor.username, note,
|
||||
})
|
||||
if (!moved) return { ok: false, status: 409, error: 'request was decided by someone else' }
|
||||
|
||||
const team = await teamsDb.findById(request.team_id)
|
||||
if (status === 'approved' && team) {
|
||||
await applyAction({
|
||||
req,
|
||||
actor,
|
||||
team,
|
||||
action: request.action,
|
||||
payload: parsePayload(request.payload),
|
||||
reason: request.reason,
|
||||
})
|
||||
}
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: `team.moderation.${status}`,
|
||||
detail: `${actor.username} (#${actor.id}) ${status} request #${requestId} `
|
||||
+ `("${request.action}" on team #${request.team_id}, asked by ${request.requested_username || 'a deleted user'})`
|
||||
+ `${note ? `: "${note}"` : ''}`,
|
||||
})
|
||||
return { ok: true, applied: status === 'approved' }
|
||||
}
|
||||
|
||||
// The driver returns JSON columns already parsed on some versions and as a string
|
||||
// on others, so this normalises rather than assuming either.
|
||||
function parsePayload(payload) {
|
||||
if (payload == null) return {}
|
||||
if (typeof payload === 'object') return payload
|
||||
try {
|
||||
return JSON.parse(payload)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
screenForCreate,
|
||||
rescreen,
|
||||
requestOrApply,
|
||||
hide,
|
||||
decide,
|
||||
reviewQueue: moderationDb.reviewQueue,
|
||||
listRequests: moderationDb.listRequests,
|
||||
pendingForTeam: moderationDb.pendingForTeam,
|
||||
GATED_ACTIONS,
|
||||
}
|
||||
223
server/src/model/teams/teamNotify.db.js
Normal file
223
server/src/model/teams/teamNotify.db.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// SQL for Team notification recipients and per-Team preferences (TEAMS.md Part 6).
|
||||
//
|
||||
// **The recipient set is the whole of Team scoping.** The four `team.*` streams
|
||||
// are global and carry no Team in their id; who an event reaches is decided here.
|
||||
// That is §6.2's design and it is not an optimisation — the push catalog is a
|
||||
// static registration validated at boot, so a stream per Team is unexpressible,
|
||||
// and stream ids live in `notification_subscriptions` rows that a per-Team id
|
||||
// would leave behind every time a Team archived.
|
||||
//
|
||||
// **One recipient query serves all four streams**, because the two populations in
|
||||
// §6.2's table are the same set written twice: "active members with a user_id,
|
||||
// plus active forum grants" IS "everyone with resolved forum access", by the
|
||||
// definition of teamAccess.forumAccess() (membership OR grant). What differs
|
||||
// between the streams is only who is subtracted — the author of the post that
|
||||
// caused it — and that is a caller's argument, not a second query.
|
||||
//
|
||||
// **Mutes are subtracted in SQL, not in the caller.** A recipient list that came
|
||||
// back complete and was filtered afterwards would be one refactor away from being
|
||||
// used unfiltered; there is no function here that returns an unmuted set.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// The union, as a derived table both recipient functions build on. Written once
|
||||
// so that "who is in a Team for notification purposes" has exactly one definition.
|
||||
//
|
||||
// `status = 'active'` on the membership half and `revoked_at IS NULL` on the
|
||||
// grant half are the same two conditions the access resolver uses; a departed
|
||||
// member and a revoked guest are both people who could still be read a private
|
||||
// forum by a query that forgot one.
|
||||
const RECIPIENT_UNION = `
|
||||
SELECT user_id FROM team_members
|
||||
WHERE team_id = ? AND status = 'active' AND user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT user_id FROM team_forum_grants
|
||||
WHERE team_id = ? AND revoked_at IS NULL`
|
||||
|
||||
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
|
||||
// integer, so a null slipping into a caller's list would become user id 0 and
|
||||
// ride into an IN clause. No row has id 0, so it is harmless today — which is
|
||||
// exactly why it would never be noticed.
|
||||
const isUserId = (n) => Number.isInteger(n) && n > 0
|
||||
|
||||
/**
|
||||
* Every user id that may be notified about `teamId`, mutes already removed.
|
||||
*
|
||||
* `exclude` is the author of the thing that happened. Passed rather than removed
|
||||
* afterwards for the reason in the header, and taken as a list because a caller
|
||||
* with nobody to exclude should not have to invent a sentinel.
|
||||
*/
|
||||
async function recipientIds(teamId, { exclude = [] } = {}) {
|
||||
const skip = [...new Set(exclude.map(Number).filter(isUserId))]
|
||||
const notMe = skip.length ? `AND r.user_id NOT IN (${skip.map(() => '?').join(',')})` : ''
|
||||
const rows = await query(
|
||||
`SELECT DISTINCT r.user_id
|
||||
FROM (${RECIPIENT_UNION}) r
|
||||
LEFT JOIN team_notification_prefs p ON p.user_id = r.user_id AND p.team_id = ?
|
||||
WHERE COALESCE(p.muted, 0) = 0 ${notMe}`,
|
||||
[teamId, teamId, teamId, ...skip],
|
||||
)
|
||||
return rows.map((r) => Number(r.user_id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The same set, narrowed to those reachable by EMAIL and carrying each one's mode.
|
||||
*
|
||||
* A separate query rather than a join onto `recipientIds` because email has two
|
||||
* conditions push does not: an address to send to, and an account still allowed to
|
||||
* have one. A banned or disabled account keeps its forum grant in the ledger —
|
||||
* revoking it is a separate staff decision — but must not keep receiving the
|
||||
* Team's private discussion in its inbox.
|
||||
*
|
||||
* `email_mode` is COALESCEd to the column default rather than read as NULL — and
|
||||
* that default is `'off'`, so this query returns the whole set with most of it
|
||||
* marked as not wanting mail. Filtering to a mode is the CALLER's job, because
|
||||
* `immediate` and `digest` are consumed by two different senders.
|
||||
*/
|
||||
async function emailRecipients(teamId, { exclude = [] } = {}) {
|
||||
const skip = [...new Set(exclude.map(Number).filter(isUserId))]
|
||||
const notMe = skip.length ? `AND u.id NOT IN (${skip.map(() => '?').join(',')})` : ''
|
||||
return query(
|
||||
`SELECT u.id AS user_id, u.username, u.email,
|
||||
COALESCE(p.email_mode, 'off') AS email_mode,
|
||||
p.last_digest_at
|
||||
FROM (${RECIPIENT_UNION}) r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN team_notification_prefs p ON p.user_id = u.id AND p.team_id = ?
|
||||
WHERE COALESCE(p.muted, 0) = 0
|
||||
AND u.email IS NOT NULL AND u.email <> ''
|
||||
AND u.status = 'active' ${notMe}
|
||||
GROUP BY u.id, u.username, u.email, p.email_mode, p.last_digest_at`,
|
||||
[teamId, teamId, teamId, ...skip],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Preferences ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One row per Team this user may be notified about, whether or not a preference
|
||||
* has ever been written for it — the account screen has to offer a Team the user
|
||||
* has never touched, and a list built from the prefs table alone would be empty
|
||||
* for exactly the users who have configured nothing.
|
||||
*
|
||||
* Archived Teams appear only when a preference row exists for them, so a mute the
|
||||
* user set does not vanish from the screen the moment a guild disbands, while a
|
||||
* disbanded guild nobody configured does not linger on it forever.
|
||||
*/
|
||||
async function prefsForUser(userId) {
|
||||
return query(
|
||||
`SELECT t.id AS team_id, t.slug, t.name, t.display_name_override, t.status AS team_status,
|
||||
COALESCE(p.muted, 0) AS muted,
|
||||
COALESCE(p.email_mode, 'off') AS email_mode
|
||||
FROM teams t
|
||||
LEFT JOIN team_notification_prefs p ON p.team_id = t.id AND p.user_id = ?
|
||||
WHERE (
|
||||
EXISTS (SELECT 1 FROM team_members m
|
||||
WHERE m.team_id = t.id AND m.user_id = ? AND m.status = 'active')
|
||||
OR EXISTS (SELECT 1 FROM team_forum_grants g
|
||||
WHERE g.team_id = t.id AND g.user_id = ? AND g.revoked_at IS NULL)
|
||||
OR p.user_id IS NOT NULL
|
||||
)
|
||||
ORDER BY t.status, t.name`,
|
||||
[userId, userId, userId],
|
||||
)
|
||||
}
|
||||
|
||||
/** One Team's preference for one user, or undefined. Read by the mute toggle. */
|
||||
async function prefFor(userId, teamId) {
|
||||
const rows = await query(
|
||||
`SELECT team_id, muted, email_mode, last_digest_at
|
||||
FROM team_notification_prefs WHERE user_id = ? AND team_id = ?`,
|
||||
[userId, teamId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one preference.
|
||||
*
|
||||
* An upsert that touches ONLY the columns it was given: the one-click unsubscribe
|
||||
* writes `muted` and must not reset an `email_mode` the user chose, and the
|
||||
* settings screen writes both. `last_digest_at` is never written here — it is the
|
||||
* worker's column, and a preference change must not look like a delivery.
|
||||
*/
|
||||
async function setPref(userId, teamId, { muted, emailMode }) {
|
||||
const sets = ['updated_at = CURRENT_TIMESTAMP']
|
||||
if (muted != null) sets.push('muted = VALUES(muted)')
|
||||
if (emailMode != null) sets.push('email_mode = VALUES(email_mode)')
|
||||
await query(
|
||||
`INSERT INTO team_notification_prefs (user_id, team_id, muted, email_mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE ${sets.join(', ')}`,
|
||||
[userId, teamId, muted ? 1 : 0, emailMode || 'off'],
|
||||
)
|
||||
}
|
||||
|
||||
/** Stamp a digest as delivered. The worker's column, and its only writer. */
|
||||
async function stampDigest(userId, teamId, at) {
|
||||
await query(
|
||||
`INSERT INTO team_notification_prefs (user_id, team_id, last_digest_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
|
||||
[userId, teamId, at],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Active Teams that have had forum activity since `since` — the digest worker's
|
||||
* driving query.
|
||||
*
|
||||
* Driven from ACTIVITY rather than from the prefs table, which is what makes the
|
||||
* worker's cost proportional to what was WRITTEN rather than to how many people
|
||||
* once opened a settings screen. A Team nobody posted in costs one row of this
|
||||
* query and no recipient computation at all.
|
||||
*/
|
||||
async function teamsWithForumActivitySince(since) {
|
||||
return query(
|
||||
`SELECT DISTINCT t.id, t.slug, t.name, t.display_name_override
|
||||
FROM teams t
|
||||
JOIN team_forum_threads th ON th.team_id = t.id
|
||||
JOIN team_forum_posts po ON po.thread_id = th.id
|
||||
WHERE t.status = 'active'
|
||||
AND po.created_at > ?
|
||||
AND po.status = 'visible'
|
||||
AND th.status = 'visible'`,
|
||||
[since],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The posts one digest covers: visible posts in visible threads, newer than the
|
||||
* recipient's own `since`.
|
||||
*
|
||||
* Re-read at send time rather than accumulated at publish time. A queue of pending
|
||||
* items would have to be garbage-collected, would replay a backlog after an outage,
|
||||
* and — the reason that actually matters — could email a body a moderator hid in
|
||||
* between. This query cannot: a hidden post is simply not in it.
|
||||
*/
|
||||
async function digestPostsSince(teamId, since, limit = 20) {
|
||||
return query(
|
||||
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username,
|
||||
th.title, th.type
|
||||
FROM team_forum_posts po
|
||||
JOIN team_forum_threads th ON th.id = po.thread_id
|
||||
WHERE th.team_id = ?
|
||||
AND po.created_at > ?
|
||||
AND po.status = 'visible'
|
||||
AND th.status = 'visible'
|
||||
ORDER BY po.created_at
|
||||
LIMIT ?`,
|
||||
[teamId, since, Number(limit)],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recipientIds,
|
||||
emailRecipients,
|
||||
prefsForUser,
|
||||
prefFor,
|
||||
setPref,
|
||||
stampDigest,
|
||||
teamsWithForumActivitySince,
|
||||
digestPostsSince,
|
||||
}
|
||||
146
server/src/model/teams/teamNotify.model.js
Normal file
146
server/src/model/teams/teamNotify.model.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// Per-Team notification preferences, and the recipient sets built from them
|
||||
// (TEAMS.md §6.2–§6.4, phase 6).
|
||||
//
|
||||
// **The absence of a row is the default, and the two sinks default OPPOSITE ways.**
|
||||
// Push is opt-out: a user in one Team must never have to configure anything to be
|
||||
// tickled about it, and the per-Team mute is how they stop. Email is opt-IN
|
||||
// (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call):
|
||||
// turning on Gmail in the admin panel must not start sending daily mail to every
|
||||
// member of every Team on the deployment.
|
||||
//
|
||||
// Both are read the same way — COALESCE to the column default, never treat a
|
||||
// missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and
|
||||
// not in a condition anybody has to remember.
|
||||
//
|
||||
// **This file never decides who may READ a Team.** It asks the same two tables
|
||||
// teamAccess.forumAccess() asks, in one query, because a fan-out cannot afford a
|
||||
// round trip per recipient — but it asks them for the same answer. If the access
|
||||
// rule ever changes, both must; the SQL in teamNotify.db.js says so at the union
|
||||
// it builds on, and the test that matters is the one asserting a revoked guest
|
||||
// receives nothing.
|
||||
|
||||
const db = require('./teamNotify.db')
|
||||
|
||||
// Stored as an ENUM, restated here because a value arriving from a request body
|
||||
// must be checked against something in JavaScript before it reaches the column —
|
||||
// a bad value would otherwise be a 500 from the driver rather than a 400 from us.
|
||||
const EMAIL_MODES = ['off', 'digest', 'immediate']
|
||||
|
||||
const isEmailMode = (v) => EMAIL_MODES.includes(v)
|
||||
|
||||
function publicPref(row) {
|
||||
return {
|
||||
teamId: Number(row.team_id),
|
||||
slug: row.slug,
|
||||
// The same `display_name_override || name` rule every other Team surface
|
||||
// uses (§2.8.3). A notification screen showing the raw name would show a name
|
||||
// staff have deliberately replaced everywhere else.
|
||||
name: row.display_name_override || row.name,
|
||||
archived: row.team_status === 'archived',
|
||||
muted: Boolean(Number(row.muted)),
|
||||
emailMode: row.email_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/** Every Team this user could be notified about, with its current preference. */
|
||||
async function listPrefs(userId) {
|
||||
return (await db.prefsForUser(userId)).map(publicPref)
|
||||
}
|
||||
|
||||
/** One Team's preference for one user, defaults applied. Never null. */
|
||||
async function prefFor(userId, teamId) {
|
||||
const row = await db.prefFor(userId, teamId)
|
||||
return {
|
||||
teamId: Number(teamId),
|
||||
muted: Boolean(row && Number(row.muted)),
|
||||
emailMode: (row && row.email_mode) || 'off',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this user's whole set of Team preferences.
|
||||
*
|
||||
* PUT-the-whole-set, matching the existing subscription endpoint, and the
|
||||
* Android gotcha carried forward from `docs/android/PLAN.md` §11 applies to the
|
||||
* ROUTE rather than to this function: the array is required even when empty.
|
||||
*
|
||||
* **A preference may only be written for a Team the caller is actually in.** The
|
||||
* ids are checked against `listPrefs`, not trusted from the body — otherwise any
|
||||
* authenticated user could write a row naming any Team, which is a (small) write
|
||||
* primitive into a table keyed by someone else's private membership. Unknown ids
|
||||
* are dropped rather than 400'd: a Team the user left between loading the screen
|
||||
* and saving it is an ordinary race, not a client bug.
|
||||
*/
|
||||
async function replacePrefs(userId, entries) {
|
||||
const allowed = new Map((await listPrefs(userId)).map((p) => [p.teamId, p]))
|
||||
const written = []
|
||||
for (const entry of entries) {
|
||||
const teamId = Number(entry && entry.teamId)
|
||||
if (!allowed.has(teamId)) continue
|
||||
const emailMode = isEmailMode(entry.emailMode) ? entry.emailMode : 'off'
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setPref(userId, teamId, { muted: Boolean(entry.muted), emailMode })
|
||||
written.push(teamId)
|
||||
}
|
||||
|
||||
// A Team the caller COULD have named and did not is returned to its defaults.
|
||||
//
|
||||
// Without this, "replace the whole set" was a lie the endpoint told: omitting an
|
||||
// entry left the old preference standing, which made `teams: []` — the body the
|
||||
// route requires precisely so that clearing everything is expressible — clear
|
||||
// nothing at all.
|
||||
//
|
||||
// Reset rather than deleted, and the difference is `last_digest_at`. That column
|
||||
// is the digest worker's state, not a preference; dropping the row with it would
|
||||
// make every visit to the settings screen re-open a day-wide digest window and
|
||||
// mail somebody a summary they already read.
|
||||
for (const teamId of allowed.keys()) {
|
||||
if (written.includes(teamId)) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setPref(userId, teamId, { muted: false, emailMode: 'off' })
|
||||
}
|
||||
|
||||
return { written, prefs: await listPrefs(userId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute one Team for one user — the one-click unsubscribe's only effect.
|
||||
*
|
||||
* Deliberately narrow. The unsubscribe link is reached without a session, so what
|
||||
* it can do is what an attacker holding a leaked link can do: silence one Team's
|
||||
* notifications for one account, visibly and reversibly on the account screen.
|
||||
* It writes no other column, and there is no "unsubscribe from everything".
|
||||
*/
|
||||
async function mute(userId, teamId) {
|
||||
await db.setPref(userId, teamId, { muted: true })
|
||||
}
|
||||
|
||||
/** Un-mute, for the toggle's other half. */
|
||||
async function unmute(userId, teamId) {
|
||||
await db.setPref(userId, teamId, { muted: false })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EMAIL_MODES,
|
||||
isEmailMode,
|
||||
listPrefs,
|
||||
prefFor,
|
||||
replacePrefs,
|
||||
mute,
|
||||
unmute,
|
||||
// Recipient sets, passed through so callers depend on the model rather than on
|
||||
// the SQL. The fan-out in utils/teamNotify.js and the digest worker are the only
|
||||
// callers.
|
||||
//
|
||||
// Wrapped rather than re-exported (`recipientIds: db.recipientIds`), which is
|
||||
// the obvious shorter form and is wrong: that captures the function OBJECT at
|
||||
// require time, so the layer below can never be substituted afterwards — which
|
||||
// makes the db layer untestable in isolation and, more to the point, means the
|
||||
// model is not really the seam it claims to be. These resolve `db.x` at call
|
||||
// time, so the boundary is real.
|
||||
recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
|
||||
emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
|
||||
stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
|
||||
teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
|
||||
digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),
|
||||
}
|
||||
239
server/src/model/teams/teamProvider.js
Normal file
239
server/src/model/teams/teamProvider.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// ── Calling the Team provider ──────────────────────────────────────────────
|
||||
//
|
||||
// The one place core asks a module a question and waits for the answer
|
||||
// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1:
|
||||
//
|
||||
// **Module unavailability is staleness, never emptiness.**
|
||||
//
|
||||
// No Team subsystem may apply a destructive result derived from a failed,
|
||||
// timed-out or unanswered module call. This file is where "failed" is defined, and
|
||||
// it is deliberately generous about what counts: a rejected promise, a timeout, a
|
||||
// non-object, a missing `ok`, or a structurally malformed row all leave with the
|
||||
// same `{ ok: false }` the module would have sent deliberately.
|
||||
//
|
||||
// **There is no shape a failure can take that core reads as "zero teams".** That
|
||||
// is the whole argument for the envelope, and the reason the provider signature is
|
||||
// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape,
|
||||
// `[]`, and it is the one a module returns while its sidecar is still connecting.
|
||||
//
|
||||
// Nothing here touches the database. It calls the module and hands back a value
|
||||
// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question.
|
||||
|
||||
const registries = require('../../modules/registries')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
// The budget from §2.3. A provider is answering from its own cache or its own
|
||||
// sidecar client, both of which have their own timeouts well inside this; a call
|
||||
// that reaches ten seconds is wedged, not slow.
|
||||
const CALL_TIMEOUT_MS = 10_000
|
||||
|
||||
/** A uniform refusal. `reason` is for the operator, via team_sync_state. */
|
||||
const fail = (reason) => ({ ok: false, reason })
|
||||
|
||||
/**
|
||||
* Await `promise` with a timeout that cannot outlive the call.
|
||||
*
|
||||
* The timer is always cleared — including on the winning path — because an
|
||||
* uncleared 10s timer holds the event loop open, which in a test run means the
|
||||
* process hangs long after the assertions passed. The suite already learned this
|
||||
* one from a mariadb pool (test/_setup.js).
|
||||
*
|
||||
* It is also `unref`ed, which covers the case clearing cannot: when the module's
|
||||
* promise NEVER settles, the race stays pending and there is nothing to clear
|
||||
* until the deadline fires. An unreffed timer still fires normally while the
|
||||
* process is alive — the server's own listener is what keeps it alive — but it no
|
||||
* longer holds a shutdown open for ten seconds waiting on a module that is not
|
||||
* going to answer.
|
||||
*/
|
||||
function withTimeout(promise, ms) {
|
||||
let timer
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms)
|
||||
if (typeof timer.unref === 'function') timer.unref()
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Call one provider method and normalise whatever comes back into an envelope.
|
||||
*
|
||||
* `normalise` is only ever run on an `ok` answer, and may itself return a refusal
|
||||
* — a structurally malformed row is treated as a failed call rather than as data
|
||||
* to salvage. Salvaging is the dangerous option: dropping one unreadable member
|
||||
* from a roster is indistinguishable, downstream, from that member having left,
|
||||
* and the sync would mark them departed. Refusing costs one stale interval.
|
||||
*/
|
||||
async function call(method, normalise, ...args) {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
if (!provider) return fail('no team provider is registered')
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS)
|
||||
} catch (err) {
|
||||
// A rejected promise is a module that threw, which is exactly as
|
||||
// unauthoritative as one that answered `{ ok: false }`.
|
||||
return fail(`${method}() threw: ${err.message}`)
|
||||
}
|
||||
|
||||
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
|
||||
return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`)
|
||||
}
|
||||
// `ok` must be present and true. A module that forgot the field is not one
|
||||
// asserting authority, and reading a missing field as truthy would put the
|
||||
// single most consequential decision in this file on a typo.
|
||||
if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`)
|
||||
|
||||
const normalised = normalise(answer)
|
||||
if (normalised.ok === false) {
|
||||
log.warn('team provider answered with a malformed payload', {
|
||||
owner: provider.owner, method, reason: normalised.reason,
|
||||
})
|
||||
}
|
||||
return normalised
|
||||
}
|
||||
|
||||
// `complete` defaults to TRUE when the module omits it, matching §2.3: the
|
||||
// envelope's optional field marks a partial answer, so its absence is the
|
||||
// ordinary authoritative case. A module that cannot enumerate exhaustively says
|
||||
// so explicitly.
|
||||
const isComplete = (answer) => answer.complete !== false
|
||||
|
||||
const str = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||
|
||||
/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */
|
||||
function normaliseTeams(answer) {
|
||||
if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array')
|
||||
const teams = []
|
||||
for (const raw of answer.teams) {
|
||||
const externalId = str(raw && raw.externalId)
|
||||
const name = str(raw && raw.name)
|
||||
// Both are load-bearing and neither has a safe default: externalId is the
|
||||
// identity the whole rename rule (§2.2) turns on, and a Team with no name has
|
||||
// no slug and no page.
|
||||
if (!externalId) return fail('a team in getTeams() has no externalId')
|
||||
if (!name) return fail(`team "${externalId}" has no name`)
|
||||
teams.push({
|
||||
externalId,
|
||||
name,
|
||||
abbr: str(raw.abbr) || null,
|
||||
// Opaque by contract (§10.5) — stored and handed back, never branched on.
|
||||
meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null,
|
||||
})
|
||||
}
|
||||
return { ok: true, complete: isComplete(answer), teams }
|
||||
}
|
||||
|
||||
/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */
|
||||
function normaliseMembers(answer) {
|
||||
if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array')
|
||||
const members = []
|
||||
const seen = new Set()
|
||||
for (const raw of answer.members) {
|
||||
const memberKey = str(raw && raw.memberKey)
|
||||
if (!memberKey) return fail('a member has no memberKey')
|
||||
// A duplicate key would upsert twice and inflate no count but confuse every
|
||||
// reader; it also means the module's own identity rule is broken, which is
|
||||
// worth surfacing rather than quietly collapsing.
|
||||
if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`)
|
||||
seen.add(memberKey)
|
||||
members.push({
|
||||
memberKey,
|
||||
displayName: str(raw.displayName) || null,
|
||||
rankLabel: str(raw.rankLabel) || null,
|
||||
leader: Boolean(raw.leader),
|
||||
online: Boolean(raw.online),
|
||||
// Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core
|
||||
// takes the number and never looks it up.
|
||||
userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null,
|
||||
})
|
||||
}
|
||||
return { ok: true, complete: isComplete(answer), members }
|
||||
}
|
||||
|
||||
/** `{ ok, leaders: [memberKey] }` */
|
||||
function normaliseLeaders(answer) {
|
||||
if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array')
|
||||
const leaders = []
|
||||
for (const raw of answer.leaders) {
|
||||
const key = str(raw)
|
||||
if (!key) return fail('a leader entry is not a member key')
|
||||
if (!leaders.includes(key)) leaders.push(key)
|
||||
}
|
||||
return { ok: true, leaders }
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer.
|
||||
*
|
||||
* Deliberately a set of keys rather than a set of rows. Core already holds the
|
||||
* rows and knows their public shape; asking the module for rows back would let a
|
||||
* module widen what is published — re-adding a `userId` or a `memberKey` that
|
||||
* §3.2 says is never published — and core's field guarantee would then rest on
|
||||
* every module's good behaviour rather than on core. So the module answers the
|
||||
* question it actually owns (who may be seen at this rung) and core keeps the
|
||||
* question it owns (what a member row looks like in public).
|
||||
*/
|
||||
function normaliseVisibleKeys(answer) {
|
||||
if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array')
|
||||
const keys = []
|
||||
for (const raw of answer.members) {
|
||||
const key = str(raw)
|
||||
if (!key) return fail('a projectRoster() entry is not a member key')
|
||||
if (!keys.includes(key)) keys.push(key)
|
||||
}
|
||||
return { ok: true, members: keys }
|
||||
}
|
||||
|
||||
const getTeams = () => call('getTeams', normaliseTeams)
|
||||
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
|
||||
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
|
||||
|
||||
/**
|
||||
* Ask the module which roster rows this viewer may see (§3.3).
|
||||
*
|
||||
* The per-audience projection is the module's because the visibility framework
|
||||
* and its rung configuration are module-owned (§10.5) — core does not know what a
|
||||
* rung is. Core supplies the roster and a description of the viewer; the module
|
||||
* returns the member keys it permits.
|
||||
*
|
||||
* **"No audience model" and "could not answer" are different, and the caller must
|
||||
* be able to tell them apart** — so the refusal carries `projects`.
|
||||
*
|
||||
* `projects: false` — no provider is registered, or the registered one does not
|
||||
* implement `projectRoster`. There is no rung system to consult and nothing
|
||||
* is being withheld; the roster is served at core's public shape. This is why
|
||||
* the member is OPTIONAL: bare core, and a module with no audience model of
|
||||
* its own, both render exactly the page core writes.
|
||||
*
|
||||
* `projects: true` — the module HAS an audience model and core could not reach
|
||||
* it (refused, threw, timed out, answered malformed). Here the caller must
|
||||
* fail CLOSED, because "leave it alone" would mean publishing the very rows
|
||||
* the rungs exist to withhold. This is the one place in the Team subsystem
|
||||
* where unavailability is not staleness: everywhere else a refused call
|
||||
* leaves data alone, and doing that to a *visibility* question is a leak.
|
||||
*/
|
||||
async function projectRoster(externalId, members, viewer) {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
if (!provider) return { ...fail('no team provider is registered'), projects: false }
|
||||
if (typeof provider.projectRoster !== 'function') {
|
||||
return { ...fail('provider does not project rosters'), projects: false }
|
||||
}
|
||||
const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer)
|
||||
return answer.ok ? answer : { ...answer, projects: true }
|
||||
}
|
||||
|
||||
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
|
||||
const providerModuleId = () => {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
return provider ? provider.owner : null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTeams,
|
||||
getTeamMembers,
|
||||
getTeamLeaders,
|
||||
projectRoster,
|
||||
providerModuleId,
|
||||
CALL_TIMEOUT_MS,
|
||||
}
|
||||
50
server/src/model/teams/teamSlug.js
Normal file
50
server/src/model/teams/teamSlug.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1).
|
||||
//
|
||||
// A slug is derived ONCE, at create, and then frozen for the life of the row —
|
||||
// like `name`, and for the same reason: the Team page URL has to stay stable, and
|
||||
// a rename is an archive plus a create rather than an edit.
|
||||
|
||||
const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix
|
||||
|
||||
/**
|
||||
* Reduce a name to a URL-safe stem.
|
||||
*
|
||||
* Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark"
|
||||
* and not "nderdark". A name made entirely of characters that do not survive —
|
||||
* which a guild name genuinely can be, since the game accepts far more than a URL
|
||||
* does — leaves an empty stem, and the caller substitutes a stable fallback
|
||||
* rather than minting a Team with no address.
|
||||
*/
|
||||
function slugify(name) {
|
||||
return String(name || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_SLUG)
|
||||
.replace(/-+$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A slug not already taken, given the ones that are.
|
||||
*
|
||||
* `taken` must include ARCHIVED teams' slugs, not only active ones. The unique
|
||||
* key constrains active rows alone, so the database would allow a new Team to
|
||||
* take a retired Team's slug — and §2.2 promises the retired one stays readable
|
||||
* at that address, which is what a bookmark or an old Discord link resolves to.
|
||||
*/
|
||||
function uniqueSlug(name, taken, { fallback = 'team' } = {}) {
|
||||
const base = slugify(name) || fallback
|
||||
const used = new Set(taken)
|
||||
if (!used.has(base)) return base
|
||||
// Bounded rather than unbounded: a suffix search that cannot terminate is worse
|
||||
// than a slug with an id in it, and 999 same-named teams is already absurd.
|
||||
for (let n = 2; n <= 999; n++) {
|
||||
const candidate = `${base}-${n}`
|
||||
if (!used.has(candidate)) return candidate
|
||||
}
|
||||
return `${base}-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
module.exports = { slugify, uniqueSlug, MAX_SLUG }
|
||||
624
server/src/model/teams/teamSync.model.js
Normal file
624
server/src/model/teams/teamSync.model.js
Normal file
@@ -0,0 +1,624 @@
|
||||
// ── The reconciler ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Core's projection of the module's Teams, kept in step (TEAMS.md §2.4). This is
|
||||
// the only thing that writes `team_members`, and one of only two things that
|
||||
// write `teams.status`.
|
||||
//
|
||||
// **The four places it refuses to act** are the point of the file, and they are
|
||||
// all one rule stated four ways: *a result derived from an answer core does not
|
||||
// trust is never applied.* Anything less specific tends to collapse, under
|
||||
// maintenance, into "a failed call means no teams" — which is invariant 1's
|
||||
// failure mode and would empty every roster on the site the first time a sidecar
|
||||
// restarted.
|
||||
//
|
||||
// 1. `getTeams()` not ok → write sync state, touch NOTHING, return.
|
||||
// 2. ok but empty, core holds ≥1 → quarantine; apply only if the NEXT
|
||||
// authoritative answer agrees.
|
||||
// 3. `getTeamMembers()` not ok → that Team's roster untouched and stale;
|
||||
// the other Teams carry on.
|
||||
// 4. ok but zero members, had some → the same two-strikes quarantine, per Team.
|
||||
//
|
||||
// Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
|
||||
// start is the one failure indistinguishable from a real wipe. "Every Team on the
|
||||
// shard disbanded at once" costs one interval of delay to confirm; getting it
|
||||
// wrong costs every roster on the site.
|
||||
//
|
||||
// Events (§2.3) are an OPTIMISATION, never the source of truth. They make the
|
||||
// common case immediate; reconciliation is what makes it correct. Nothing
|
||||
// destructive at Team level is ever driven by one — §2.2 scopes archival to an
|
||||
// authoritative full list, so a `team.disbanded` event schedules a run rather
|
||||
// than archiving, and a spurious event costs a reconcile instead of a Team.
|
||||
|
||||
const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const moderation = require('./teamModeration.model')
|
||||
const activity = require('./teamActivity.model')
|
||||
const teamNotify = require('../../utils/teamNotify')
|
||||
const teamVoiceSync = require('../../utils/teamVoiceSync')
|
||||
const { slugify, uniqueSlug } = require('./teamSlug')
|
||||
const settings = require('../settings/settings.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
// At most one run per 30s (§2.4), so a sidecar flapping cannot become a
|
||||
// reconciliation storm — every flap publishes events, and every event asks for a
|
||||
// run.
|
||||
const DEBOUNCE_MS = 30_000
|
||||
const DEFAULT_INTERVAL_S = 900
|
||||
const MIN_INTERVAL_S = 60
|
||||
const INTERVAL_KEY = 'teams_reconcile_interval_s'
|
||||
|
||||
// The six kinds a module may publish (§2.3). Six rather than the four a
|
||||
// membership-shaped reading suggests, because leadership is its own authority
|
||||
// path and a leadership change must be expressible without pretending someone
|
||||
// joined or left.
|
||||
const EVENT_KINDS = new Set([
|
||||
'team.created', 'team.disbanded',
|
||||
'team.member.added', 'team.member.removed',
|
||||
'team.leader.added', 'team.leader.removed',
|
||||
])
|
||||
|
||||
// Kinds that can only be answered by a full list. `team.created` cannot be
|
||||
// applied from a delta — a Team built from one has no name, no roster and no
|
||||
// leaders — and `team.disbanded` must not be, per §2.2.
|
||||
const RECONCILE_ONLY = new Set(['team.created', 'team.disbanded'])
|
||||
|
||||
// ── Scheduling state (in-process; one provider per deployment) ─────────────
|
||||
|
||||
let running = false
|
||||
let rerunReason = null
|
||||
let lastRunAt = 0
|
||||
let debounceTimer = null
|
||||
let pollTimer = null
|
||||
let started = false
|
||||
|
||||
/** Resolve the poll interval, floored so a bad setting cannot become a hot loop. */
|
||||
async function intervalSeconds() {
|
||||
let raw
|
||||
try {
|
||||
raw = await settings.get(INTERVAL_KEY)
|
||||
} catch {
|
||||
return DEFAULT_INTERVAL_S
|
||||
}
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(n) || n < MIN_INTERVAL_S) return DEFAULT_INTERVAL_S
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff, capped at the poll interval (§2.4).
|
||||
*
|
||||
* The cap is what keeps this a backoff rather than an outage: a module down for a
|
||||
* day would otherwise reach a delay measured in weeks and stay stale long after
|
||||
* it recovered.
|
||||
*/
|
||||
function backoffSeconds(consecutiveFailures, intervalS) {
|
||||
if (!consecutiveFailures) return intervalS
|
||||
return Math.min(intervalS, 2 ** Math.min(consecutiveFailures, 16) * 15)
|
||||
}
|
||||
|
||||
// ── Applying one Team ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the row for a Team core has not seen, deriving its slug and screening
|
||||
* its name against the reserved list (§2.8).
|
||||
*
|
||||
* The row is created whatever the screening says, and hidden if it matched. Core
|
||||
* cannot refuse a name: the guild already exists in the game and core is a mirror
|
||||
* of it, not an authority over it. A hidden Team is absent from public surfaces
|
||||
* and completely functional for its own members — the people in it are not being
|
||||
* punished for a name their leader chose.
|
||||
*/
|
||||
async function createTeam(moduleId, team) {
|
||||
const taken = await teamsDb.slugsLike(slugify(team.name) || 'team')
|
||||
const slug = uniqueSlug(team.name, taken)
|
||||
const screened = await moderation.screenForCreate(team.name)
|
||||
const id = await teamsDb.insertTeam({ moduleId, slug, ...team, ...screened })
|
||||
log.info('team created', {
|
||||
moduleId, externalId: team.externalId, name: team.name, slug, hidden: Boolean(screened.hidden),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* The §2.2 rename rule: same id and a different name is an archive plus a create.
|
||||
*
|
||||
* The old row keeps its forum, its activity and its grants, all read-only, and
|
||||
* points at its successor so the old slug can explain itself instead of 404ing.
|
||||
* Core never decides whether this is "really" the same team — that judgement is
|
||||
* the module's, expressed in whether it reuses the external id (§10.5).
|
||||
*/
|
||||
async function applyRename(moduleId, existing, team) {
|
||||
const successorId = await createTeam(moduleId, team)
|
||||
await teamsDb.archiveTeam(existing.id, 'renamed', successorId)
|
||||
log.info('team renamed; previous row archived', {
|
||||
externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId,
|
||||
})
|
||||
// §4.2's `core.team.renamed`, written to the SUCCESSOR rather than to the row
|
||||
// that was renamed: the archived row is a read-only record of what happened
|
||||
// before the rename (§2.2), and the person who wants to know a Team used to be
|
||||
// called something else is looking at the live page.
|
||||
//
|
||||
// The old name is core's own, not game-sourced text a module handed us this
|
||||
// run — it is the `name` column core has been serving all along — so §2.9's
|
||||
// approval gate does not apply. It can still be a name staff suppressed, which
|
||||
// is why a hidden Team's feed is not served publicly (teamActivity.feedFor).
|
||||
await activity.logCore({
|
||||
teamId: successorId,
|
||||
kind: activity.CORE_KINDS.TEAM_RENAMED,
|
||||
summary: `Renamed from ${existing.display_name_override || existing.name}`,
|
||||
dedupeKey: `renamed:${existing.id}`,
|
||||
}).catch((err) => log.warn('rename activity not recorded', { message: err.message }))
|
||||
return successorId
|
||||
}
|
||||
|
||||
/** Never a game-internal member key on a public page: that identifier is not published (§3.2). */
|
||||
const memberLabel = (row) => (row && row.display_name) || 'A member'
|
||||
|
||||
/**
|
||||
* Core's own membership items for one roster run (§4.2).
|
||||
*
|
||||
* **Suppressed on a Team's FIRST roster.** Importing a 155-member guild is one
|
||||
* Team arriving, not 155 people joining, and emitting a join per member would
|
||||
* bury every real event under the import and blow through the row cap on day one.
|
||||
* `roster_synced_at IS NULL` is exactly "core has never held a roster for this
|
||||
* Team", so the same condition covers a newly created Team and a newly installed
|
||||
* module adopting an existing one.
|
||||
*
|
||||
* Never throws: the feed is a rendering of the sync, and a feed write failing
|
||||
* must not abort the sync that is the actual source of truth.
|
||||
*/
|
||||
async function logRosterActivity(team, { joined, left, promoted, demoted }) {
|
||||
if (!team.roster_synced_at) return
|
||||
|
||||
const items = [
|
||||
...joined.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_JOINED, row, verb: 'joined' })),
|
||||
...left.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_LEFT, row, verb: 'left' })),
|
||||
...promoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'became a leader' })),
|
||||
...demoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'stepped down as a leader' })),
|
||||
]
|
||||
|
||||
for (const { kind, row, verb } of items) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await activity.logCore({
|
||||
teamId: team.id,
|
||||
kind,
|
||||
summary: `${memberLabel(row)} ${verb}`,
|
||||
actorMemberKey: row.member_key,
|
||||
actorUserId: row.user_id ?? null,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('roster activity not recorded', { teamId: team.id, kind, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The push half of the same roster run (TEAMS.md §6.2, phase 6).
|
||||
*
|
||||
* **At most one tickle per stream per run, not one per member.** A tickle is
|
||||
* content-free — it says "something happened in this Team" and the app pulls the
|
||||
* rest — so five people joining in one sweep is five identical notifications and
|
||||
* one piece of information. The feed above is per-member because it is a record;
|
||||
* this is per-run because it is a nudge.
|
||||
*
|
||||
* **Suppressed on a Team's FIRST roster, exactly as the feed is**, and this is the
|
||||
* half where it matters more: importing a 155-member guild would otherwise wake
|
||||
* every one of their phones. `roster_synced_at IS NULL` is the same condition, read
|
||||
* from the same row before the same stamp moves.
|
||||
*
|
||||
* Never throws — the fan-out swallows its own failures, and this adds the guard
|
||||
* for anything the surrounding read could raise. A roster sync is the source of
|
||||
* truth; a notification about it is not.
|
||||
*/
|
||||
async function notifyRoster(team, { joined, promoted, demoted }) {
|
||||
if (!team.roster_synced_at) return
|
||||
try {
|
||||
// The count rides along for the Discord bridge (§7.2), which has no app on
|
||||
// the other end to pull the roster after a content-free nudge. The tickle
|
||||
// itself is unchanged and still carries nothing.
|
||||
if (joined.length > 0) await teamNotify.memberJoined(team, { count: joined.length })
|
||||
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
|
||||
} catch (err) {
|
||||
log.warn('roster notification not sent', { teamId: team.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
|
||||
*
|
||||
* Returns whether the roster was applied, so the caller can tell "synced" from
|
||||
* "left alone", which is the difference between fresh and stale on that Team's
|
||||
* page.
|
||||
*/
|
||||
async function syncRoster(team) {
|
||||
const answer = await teamProvider.getTeamMembers(team.external_id)
|
||||
|
||||
// Gate 3. One Team's unanswerable roster is not the other Teams' problem, and
|
||||
// it is certainly not an empty roster.
|
||||
if (!answer.ok) {
|
||||
log.warn('roster left untouched; provider could not answer', {
|
||||
externalId: team.external_id, reason: answer.reason,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// The full rows rather than just the keys: the activity feed needs the display
|
||||
// name and the prior `is_leader` of everyone who is about to change, and both
|
||||
// are gone once the upsert below has run. One read either way — this replaces
|
||||
// the `memberKeys` call rather than adding to it.
|
||||
const knownRows = await teamsDb.membersByTeam(team.id)
|
||||
const knownByKey = new Map(knownRows.map((row) => [row.member_key, row]))
|
||||
const known = knownRows.map((row) => row.member_key)
|
||||
|
||||
// Gate 4, the per-Team twin of gate 2.
|
||||
if (answer.complete && answer.members.length === 0 && known.length > 0) {
|
||||
if (!team.members_empty_since) {
|
||||
await teamsDb.setMembersEmptySince(team.id, new Date())
|
||||
log.warn('empty roster quarantined; awaiting a second answer', {
|
||||
externalId: team.external_id, had: known.length,
|
||||
})
|
||||
return false
|
||||
}
|
||||
log.warn('empty roster confirmed by a second answer; departing every member', {
|
||||
externalId: team.external_id, had: known.length,
|
||||
})
|
||||
} else if (team.members_empty_since) {
|
||||
// Any non-empty answer clears the quarantine.
|
||||
await teamsDb.setMembersEmptySince(team.id, null)
|
||||
}
|
||||
|
||||
for (const member of answer.members) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await teamsDb.upsertMember({
|
||||
teamId: team.id,
|
||||
memberKey: member.memberKey,
|
||||
displayName: member.displayName,
|
||||
userId: member.userId,
|
||||
isLeader: member.leader,
|
||||
rankLabel: member.rankLabel,
|
||||
online: member.online,
|
||||
})
|
||||
}
|
||||
|
||||
// Anyone the module reports that core was not already holding. Read from the
|
||||
// module's shape, since a joiner has no row yet.
|
||||
const joined = answer.members
|
||||
.filter((m) => !knownByKey.has(m.memberKey))
|
||||
.map((m) => ({ member_key: m.memberKey, display_name: m.displayName, user_id: m.userId }))
|
||||
|
||||
// Removals only from a COMPLETE answer. `complete: false` means "valid but
|
||||
// partial", so additions and updates apply and nothing is taken away.
|
||||
let left = []
|
||||
if (answer.complete) {
|
||||
const seen = new Set(answer.members.map((m) => m.memberKey))
|
||||
const departedKeys = known.filter((key) => !seen.has(key))
|
||||
left = departedKeys.map((key) => knownByKey.get(key))
|
||||
await teamsDb.markDeparted(team.id, departedKeys)
|
||||
}
|
||||
|
||||
// Leadership is a separate question with a separate answer, and a provider that
|
||||
// cannot answer it leaves the synced value alone rather than demoting everyone.
|
||||
const leaders = await teamProvider.getTeamLeaders(team.external_id)
|
||||
let promoted = []
|
||||
let demoted = []
|
||||
if (leaders.ok) {
|
||||
// Diffed against the PRIOR rows, before setLeaders overwrites them. A member
|
||||
// who joined this run as a leader is reported as joining, not as being
|
||||
// promoted — they were never anything else here.
|
||||
const nowLeader = new Set(leaders.leaders)
|
||||
const departed = new Set(left.map((row) => row && row.member_key))
|
||||
promoted = knownRows.filter((row) => nowLeader.has(row.member_key) && !row.is_leader)
|
||||
demoted = knownRows.filter((row) => !nowLeader.has(row.member_key) && row.is_leader && !departed.has(row.member_key))
|
||||
await teamsDb.setLeaders(team.id, leaders.leaders)
|
||||
} else {
|
||||
log.warn('leadership left untouched; provider could not answer', {
|
||||
externalId: team.external_id, reason: leaders.reason,
|
||||
})
|
||||
}
|
||||
|
||||
await teamsDb.recount(team.id)
|
||||
// Both read before `markRosterSynced` moves the stamp their first-roster
|
||||
// suppression turns on.
|
||||
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
|
||||
await notifyRoster(team, { joined, promoted, demoted })
|
||||
await teamsDb.markRosterSynced(team.id)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── The run ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One full reconciliation. Callers use `request()`; this is the body it guards.
|
||||
*
|
||||
* Never throws: a reconcile is a background job, and a rejection here would
|
||||
* surface as an unhandled rejection in the poll timer rather than as anything an
|
||||
* operator could act on. The failure is recorded where it can be read — in
|
||||
* `team_sync_state`, which Admin → Teams shows verbatim.
|
||||
*/
|
||||
async function runOnce(reason) {
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
if (!moduleId) return { ok: false, reason: 'no team provider is registered' }
|
||||
|
||||
await teamsDb.recordAttempt(moduleId)
|
||||
const answer = await teamProvider.getTeams()
|
||||
|
||||
// Gate 1.
|
||||
if (!answer.ok) {
|
||||
await teamsDb.recordFailure(moduleId, answer.reason)
|
||||
log.warn('reconcile refused; provider could not answer', { reason: answer.reason, trigger: reason })
|
||||
return { ok: false, reason: answer.reason }
|
||||
}
|
||||
|
||||
const existing = await teamsDb.activeByModule(moduleId)
|
||||
|
||||
// Gate 2. Only a COMPLETE answer can mean "there are no teams" — a partial one
|
||||
// removes nothing by definition.
|
||||
if (answer.complete && answer.teams.length === 0 && existing.length > 0) {
|
||||
const state = await teamsDb.syncState(moduleId)
|
||||
if (!state || !state.pending_empty_since) {
|
||||
await teamsDb.setPendingEmpty(moduleId, new Date())
|
||||
await teamsDb.recordSuccess(moduleId)
|
||||
log.warn('empty team list quarantined; awaiting a second answer', { held: existing.length })
|
||||
return { ok: true, quarantined: true, applied: 0 }
|
||||
}
|
||||
const intervalS = await intervalSeconds()
|
||||
const waited = (Date.now() - new Date(state.pending_empty_since).getTime()) / 1000
|
||||
if (waited < intervalS) {
|
||||
await teamsDb.recordSuccess(moduleId)
|
||||
log.warn('empty team list still quarantined', { waitedSeconds: Math.round(waited), intervalS })
|
||||
return { ok: true, quarantined: true, applied: 0 }
|
||||
}
|
||||
log.warn('empty team list confirmed; archiving every active team', { count: existing.length })
|
||||
} else if (answer.teams.length) {
|
||||
// Any non-empty answer clears the quarantine.
|
||||
await teamsDb.setPendingEmpty(moduleId, null)
|
||||
}
|
||||
|
||||
const byExternalId = new Map(existing.map((t) => [t.external_id, t]))
|
||||
const seen = new Set()
|
||||
let created = 0
|
||||
let renamed = 0
|
||||
let rosters = 0
|
||||
|
||||
for (const team of answer.teams) {
|
||||
seen.add(team.externalId)
|
||||
const current = byExternalId.get(team.externalId)
|
||||
let id
|
||||
if (!current) {
|
||||
id = await createTeam(moduleId, team)
|
||||
created += 1
|
||||
} else if (current.name !== team.name) {
|
||||
id = await applyRename(moduleId, current, team)
|
||||
renamed += 1
|
||||
} else {
|
||||
id = current.id
|
||||
await teamsDb.updateTeam(id, { abbr: team.abbr, meta: team.meta })
|
||||
}
|
||||
|
||||
// Re-read rather than reusing `current`: a create or a rename has just made a
|
||||
// row this loop has never seen, and syncRoster reads the quarantine stamp off
|
||||
// it. Passing a stale object would drop the second strike of gate 4.
|
||||
const row = await teamsDb.findById(id)
|
||||
if (row && await syncRoster(row)) rosters += 1
|
||||
}
|
||||
|
||||
// Archive what the module no longer lists — the §2.2 disband path, and the only
|
||||
// one. Guarded by `complete` for the same reason removals are.
|
||||
let archived = 0
|
||||
if (answer.complete) {
|
||||
for (const team of existing) {
|
||||
if (seen.has(team.external_id)) continue
|
||||
await teamsDb.archiveTeam(team.id, 'disbanded')
|
||||
archived += 1
|
||||
log.info('team archived; absent from an authoritative list', {
|
||||
externalId: team.external_id, name: team.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Re-screen the names no human has ruled on. Names are immutable per row, so
|
||||
// this only changes an outcome when the reserved TERMS changed — an operator
|
||||
// adding one, or the deployment being renamed — which is exactly the case a
|
||||
// create-time-only check would miss forever.
|
||||
const rehidden = await moderation.rescreen(moduleId)
|
||||
|
||||
await teamsDb.recordSuccess(moduleId)
|
||||
|
||||
// §7.3's "after a successful Team reconcile": the voice reconciler runs off the
|
||||
// projection this run just refreshed. Requested rather than awaited — it makes
|
||||
// Discord calls, and a roster sync must never be slowed down, failed or held
|
||||
// open by an integration hanging off it. It has its own debounce and its own
|
||||
// suspensions (including the stale check, which is why it re-reads the state
|
||||
// this run just wrote rather than trusting that it was called from a good one).
|
||||
teamVoiceSync.request({ reason: 'reconcile' })
|
||||
log.info('reconcile complete', {
|
||||
trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length,
|
||||
})
|
||||
return { ok: true, created, renamed, archived, rosters, rehidden }
|
||||
}
|
||||
|
||||
// ── The public entry points ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run now, awaited, with the per-module lock held. Admin → Resync uses this,
|
||||
* because an operator pressing a button is owed the outcome rather than a
|
||||
* promise that something will happen soon.
|
||||
*
|
||||
* A run already in progress is JOINED rather than queued: the caller wants "the
|
||||
* projection is now current", and a run that started a moment ago delivers that.
|
||||
*/
|
||||
async function reconcileNow(reason = 'manual') {
|
||||
if (running) {
|
||||
rerunReason = reason
|
||||
return { ok: true, joined: true }
|
||||
}
|
||||
running = true
|
||||
try {
|
||||
const result = await runOnce(reason)
|
||||
lastRunAt = Date.now()
|
||||
return result
|
||||
} catch (err) {
|
||||
log.error('reconcile threw', { message: err.message, trigger: reason })
|
||||
return { ok: false, reason: err.message }
|
||||
} finally {
|
||||
running = false
|
||||
const queued = rerunReason
|
||||
rerunReason = null
|
||||
// Something asked while this run was in flight, so it saw state this run may
|
||||
// have been too early to include. Ask again, through the debounce.
|
||||
if (queued) request({ reason: queued })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a reconciliation. Returns immediately and never rejects — this is what
|
||||
* `ctx.teams.reconcile()` is (§2.3), and a module must not be able to make its
|
||||
* own call site slow or its own errors someone else's.
|
||||
*/
|
||||
function request({ reason = 'module' } = {}) {
|
||||
if (debounceTimer) return
|
||||
const since = Date.now() - lastRunAt
|
||||
if (running) {
|
||||
rerunReason = reason
|
||||
return
|
||||
}
|
||||
if (since >= DEBOUNCE_MS) {
|
||||
reconcileNow(reason).catch(() => {})
|
||||
return
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
reconcileNow(reason).catch(() => {})
|
||||
}, DEBOUNCE_MS - since)
|
||||
// Unreffed for the same reason the provider's deadline is: a pending debounce
|
||||
// must not hold a shutdown open waiting to do background work.
|
||||
if (typeof debounceTimer.unref === 'function') debounceTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a module-published event (§2.3).
|
||||
*
|
||||
* Deltas are applied only for a Team core already knows, and only for the four
|
||||
* kinds a delta can express. Everything else — an unknown Team, a create, a
|
||||
* disband — asks for a reconciliation instead, because a Team invented from a
|
||||
* delta has no name, no roster and no leaders, and an archive driven by one is
|
||||
* destruction on the strength of a message that may simply have been repeated.
|
||||
*/
|
||||
async function publish(event) {
|
||||
const { kind, externalId } = event || {}
|
||||
if (!EVENT_KINDS.has(kind)) throw new Error(`teams.publish: unknown event kind "${kind}"`)
|
||||
const id = typeof externalId === 'string' ? externalId.trim() : ''
|
||||
if (!id) throw new Error(`teams.publish: ${kind} has no externalId`)
|
||||
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
if (!moduleId) return
|
||||
|
||||
if (RECONCILE_ONLY.has(kind)) {
|
||||
request({ reason: kind })
|
||||
return
|
||||
}
|
||||
|
||||
const team = await teamsDb.findActive(moduleId, id)
|
||||
if (!team) {
|
||||
request({ reason: `${kind} for an unknown team` })
|
||||
return
|
||||
}
|
||||
|
||||
const memberKey = typeof event.memberKey === 'string' ? event.memberKey.trim() : ''
|
||||
if (!memberKey) throw new Error(`teams.publish: ${kind} has no memberKey`)
|
||||
|
||||
switch (kind) {
|
||||
case 'team.member.added':
|
||||
await teamsDb.upsertMember({
|
||||
teamId: team.id,
|
||||
memberKey,
|
||||
displayName: typeof event.displayName === 'string' ? event.displayName.trim() : null,
|
||||
userId: Number.isInteger(event.userId) && event.userId > 0 ? event.userId : null,
|
||||
isLeader: Boolean(event.leader),
|
||||
rankLabel: typeof event.rankLabel === 'string' ? event.rankLabel.trim() : null,
|
||||
online: Boolean(event.online),
|
||||
})
|
||||
break
|
||||
case 'team.member.removed':
|
||||
await teamsDb.markDeparted(team.id, [memberKey])
|
||||
break
|
||||
case 'team.leader.added':
|
||||
case 'team.leader.removed':
|
||||
// A no-op when the member is unknown: the row is created by the roster, not
|
||||
// by a leadership delta, and inventing one here would put a member on the
|
||||
// roster whose only evidence is that someone promoted them.
|
||||
await teamsDb.setMemberLeader(team.id, memberKey, kind === 'team.leader.added')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
await teamsDb.recount(team.id)
|
||||
// A delta is a hint that something changed, not a claim to have applied all of
|
||||
// it, so every one still asks for the run that makes it correct.
|
||||
request({ reason: kind })
|
||||
}
|
||||
|
||||
// ── The poll ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function scheduleNextPoll() {
|
||||
const intervalS = await intervalSeconds()
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
let delayS = intervalS
|
||||
if (moduleId) {
|
||||
const state = await teamsDb.syncState(moduleId).catch(() => null)
|
||||
if (state) delayS = backoffSeconds(state.consecutive_failures, intervalS)
|
||||
}
|
||||
pollTimer = setTimeout(() => {
|
||||
reconcileNow('poll').catch(() => {}).then(() => { if (started) scheduleNextPoll().catch(() => {}) })
|
||||
}, delayS * 1000)
|
||||
if (typeof pollTimer.unref === 'function') pollTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the boot reconcile and the poll. Called from the module lifecycle, after
|
||||
* every module has started — the website may have been down across a whole guild
|
||||
* war, so the first thing it does on the way up is ask.
|
||||
*/
|
||||
async function start() {
|
||||
if (started) return
|
||||
started = true
|
||||
if (!teamProvider.providerModuleId()) {
|
||||
log.info('no team provider registered; the reconciler stays idle')
|
||||
return
|
||||
}
|
||||
await reconcileNow('boot')
|
||||
await scheduleNextPoll()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
if (pollTimer) clearTimeout(pollTimer)
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
pollTimer = null
|
||||
debounceTimer = null
|
||||
}
|
||||
|
||||
// Test-only: the scheduler is module-level state, so a test that triggers a run
|
||||
// has to be able to put it back.
|
||||
function _reset() {
|
||||
stop()
|
||||
running = false
|
||||
rerunReason = null
|
||||
lastRunAt = 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reconcileNow,
|
||||
request,
|
||||
publish,
|
||||
start,
|
||||
stop,
|
||||
intervalSeconds,
|
||||
backoffSeconds,
|
||||
EVENT_KINDS,
|
||||
DEBOUNCE_MS,
|
||||
DEFAULT_INTERVAL_S,
|
||||
_reset,
|
||||
}
|
||||
213
server/src/model/teams/teamVoice.db.js
Normal file
213
server/src/model/teams/teamVoice.db.js
Normal file
@@ -0,0 +1,213 @@
|
||||
// SQL for per-Team external resources — today, the Discord voice channel
|
||||
// (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// Two queries carry the phase. `desiredTeams` is what SHOULD have a channel and
|
||||
// `holdersWithoutClaim` is what HAS one and should not; the reconciler is the
|
||||
// difference between them, and keeping both as single queries is what stops a
|
||||
// pass from being one round trip per Team before it has made a single Discord
|
||||
// call.
|
||||
//
|
||||
// **`discordSubjectsFor` is the whole identity chain in one statement** (§2.6):
|
||||
// team_members → users → user_identities. A member with no site account has no
|
||||
// row to join, and a member with a site account but no Discord identity drops out
|
||||
// at the second join — which is exactly right, because a role can only be granted
|
||||
// to somebody Discord knows about. Nothing else in the phase is allowed to
|
||||
// shortcut this with `teams.linked_count`, which counts hop 1 and is always the
|
||||
// larger number.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// The provider id a Discord identity is stored under. Matches `auth_providers.id`
|
||||
// and the built-in provider in `auth/providers/discord.provider.js`; a constant
|
||||
// rather than a literal because it appears in two queries and a typo in either
|
||||
// would silently return an empty grant set — a Team whose channel nobody can
|
||||
// enter, with no error anywhere.
|
||||
const DISCORD_PROVIDER = 'discord'
|
||||
|
||||
// Deliberately WITHOUT `i.team_id`, and this is not tidiness.
|
||||
//
|
||||
// The two queries below join `teams` and already select `t.id AS team_id`, so
|
||||
// including the integration row's copy produces two result columns with the same
|
||||
// name — which the `mariadb` driver refuses outright: "Error in results, duplicate
|
||||
// field name `team_id`". Every caller sees the whole pass fail, and no unit test
|
||||
// can see it, because they stub this layer.
|
||||
//
|
||||
// It would also be the WRONG column even if the driver allowed it: `desiredTeams`
|
||||
// LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel
|
||||
// yet — the create case, where knowing the Team's id matters most. The two queries
|
||||
// that do not join `teams` ask for it explicitly.
|
||||
const COLUMNS = `
|
||||
i.id, i.platform, i.resource, i.external_ref, i.role_ref,
|
||||
i.state, i.remove_after, i.last_error, i.synced_at, i.updated_at`
|
||||
|
||||
/**
|
||||
* Every Team that qualifies for a resource, with its integration row if it has
|
||||
* one.
|
||||
*
|
||||
* The three conditions are §7.3's provisioning gate and §2.8's publication rule
|
||||
* together:
|
||||
*
|
||||
* - `status = 'active'` — an archived Team is a record, not a place to talk.
|
||||
* - `hidden = 0` — the channel is NAMED after the Team, and a Discord channel
|
||||
* name is a game-sourced string published outside the site. A hidden Team's
|
||||
* name is suppressed on every public surface; a voice channel would be the
|
||||
* one place it still appeared.
|
||||
* - `member_count >= ?` — the operator's threshold, counting ALL active members
|
||||
* regardless of what they have linked (org lead, 2026-08-18). §7.3 wrote
|
||||
* `voice_min_linked_members`; the number an operator is actually judging is
|
||||
* "is this Team real", and link state answers a different question.
|
||||
*
|
||||
* LEFT JOIN rather than two queries: the reconciler needs "should have, and does
|
||||
* it" as one answer, and a Team with no row yet is the create case.
|
||||
*/
|
||||
async function desiredTeams({ platform, resource, minMembers }) {
|
||||
return query(
|
||||
`SELECT t.id AS team_id, t.name, t.display_name_override, t.slug, t.abbr,
|
||||
t.member_count, t.linked_count, ${COLUMNS}
|
||||
FROM teams t
|
||||
LEFT JOIN team_integrations i
|
||||
ON i.team_id = t.id AND i.platform = ? AND i.resource = ?
|
||||
WHERE t.status = 'active' AND t.hidden = 0 AND t.member_count >= ?
|
||||
ORDER BY t.id`,
|
||||
[platform, resource, Number(minMembers)],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that hold a resource for a Team that no longer qualifies.
|
||||
*
|
||||
* The mirror of `desiredTeams`, and deliberately not its negation in JavaScript:
|
||||
* a Team can stop qualifying by being archived, by being hidden, by losing
|
||||
* members, or by having its row deleted out from under core, and enumerating
|
||||
* those in a filter would mean re-deriving the gate in a second place that could
|
||||
* disagree with the first.
|
||||
*
|
||||
* Rows already in 'pending_removal' are included — the grace window is decided by
|
||||
* the caller, which needs to see them to know whether one has expired.
|
||||
*/
|
||||
async function holdersWithoutClaim({ platform, resource, minMembers }) {
|
||||
return query(
|
||||
`SELECT t.id AS team_id, t.name, t.display_name_override, t.status, t.hidden,
|
||||
t.member_count, ${COLUMNS}
|
||||
FROM team_integrations i
|
||||
JOIN teams t ON t.id = i.team_id
|
||||
WHERE i.platform = ? AND i.resource = ?
|
||||
AND (i.external_ref IS NOT NULL OR i.role_ref IS NOT NULL)
|
||||
AND (t.status <> 'active' OR t.hidden = 1 OR t.member_count < ?)
|
||||
ORDER BY t.id`,
|
||||
[platform, resource, Number(minMembers)],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Discord user ids of a Team's members — hop 3 of §2.6, and the only set a
|
||||
* role can be granted to.
|
||||
*
|
||||
* DISTINCT because a user could in principle hold two rows for the same provider
|
||||
* across a provider rename; the unique key prevents it for one (provider,
|
||||
* subject) pair, not for one user with two subjects. Two role-adds for the same
|
||||
* person is harmless and one duplicate in a diff is a phantom removal next pass,
|
||||
* which is not.
|
||||
*/
|
||||
async function discordSubjectsFor(teamId) {
|
||||
const rows = await query(
|
||||
`SELECT DISTINCT ui.subject
|
||||
FROM team_members m
|
||||
JOIN user_identities ui ON ui.user_id = m.user_id AND ui.provider = ?
|
||||
WHERE m.team_id = ? AND m.status = 'active' AND m.user_id IS NOT NULL
|
||||
ORDER BY ui.subject`,
|
||||
[DISCORD_PROVIDER, Number(teamId)],
|
||||
)
|
||||
return rows.map((row) => String(row.subject))
|
||||
}
|
||||
|
||||
/** Every row for a platform, with the Team's name — the admin panel's listing. */
|
||||
async function listForPlatform(platform, resource) {
|
||||
return query(
|
||||
`SELECT i.team_id, ${COLUMNS}, t.name AS team_name, t.slug AS team_slug,
|
||||
t.display_name_override, t.status AS team_status, t.hidden AS team_hidden,
|
||||
t.member_count, t.linked_count
|
||||
FROM team_integrations i
|
||||
JOIN teams t ON t.id = i.team_id
|
||||
WHERE i.platform = ? AND i.resource = ?
|
||||
ORDER BY t.name`,
|
||||
[platform, resource],
|
||||
)
|
||||
}
|
||||
|
||||
async function getForTeam(teamId, platform, resource) {
|
||||
const rows = await query(
|
||||
`SELECT i.team_id, ${COLUMNS} FROM team_integrations i
|
||||
WHERE i.team_id = ? AND i.platform = ? AND i.resource = ? LIMIT 1`,
|
||||
[Number(teamId), platform, resource],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Write what the reconciler believes after a pass.
|
||||
*
|
||||
* A full upsert of the mutable columns rather than a patch, because every caller
|
||||
* has just decided all of them together: a pass that created a channel knows the
|
||||
* state, the refs, the error (none) and the stamp, and letting it write three of
|
||||
* the four would leave the fourth describing a previous pass.
|
||||
*
|
||||
* `remove_after` is written explicitly on every call, `NULL` included — a Team
|
||||
* that climbs back above the threshold inside its window has to have the window
|
||||
* cleared, and an upsert that skipped NULLs would leave it armed.
|
||||
*/
|
||||
async function upsert({ teamId, platform, resource, externalRef, roleRef, state, removeAfter, lastError, syncedAt }) {
|
||||
await query(
|
||||
`INSERT INTO team_integrations
|
||||
(team_id, platform, resource, external_ref, role_ref, state, remove_after, last_error, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
external_ref = VALUES(external_ref),
|
||||
role_ref = VALUES(role_ref),
|
||||
state = VALUES(state),
|
||||
remove_after = VALUES(remove_after),
|
||||
last_error = VALUES(last_error),
|
||||
synced_at = VALUES(synced_at)`,
|
||||
[
|
||||
Number(teamId),
|
||||
platform,
|
||||
resource,
|
||||
externalRef || null,
|
||||
roleRef || null,
|
||||
state,
|
||||
removeAfter || null,
|
||||
lastError ? String(lastError).slice(0, 500) : null,
|
||||
syncedAt || null,
|
||||
],
|
||||
)
|
||||
return getForTeam(teamId, platform, resource)
|
||||
}
|
||||
|
||||
async function remove(teamId, platform, resource) {
|
||||
const res = await query(
|
||||
'DELETE FROM team_integrations WHERE team_id = ? AND platform = ? AND resource = ?',
|
||||
[Number(teamId), platform, resource],
|
||||
)
|
||||
return Number(res && res.affectedRows) || 0
|
||||
}
|
||||
|
||||
/** How many rows currently hold a role — the input to the 250-role ceiling. */
|
||||
async function roleCount(platform) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n FROM team_integrations WHERE platform = ? AND role_ref IS NOT NULL',
|
||||
[platform],
|
||||
)
|
||||
return Number(rows[0] && rows[0].n) || 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DISCORD_PROVIDER,
|
||||
desiredTeams,
|
||||
holdersWithoutClaim,
|
||||
discordSubjectsFor,
|
||||
listForPlatform,
|
||||
getForTeam,
|
||||
upsert,
|
||||
remove,
|
||||
roleCount,
|
||||
}
|
||||
235
server/src/model/teams/teamVoice.model.js
Normal file
235
server/src/model/teams/teamVoice.model.js
Normal file
@@ -0,0 +1,235 @@
|
||||
// ── One voice channel per Team: what core believes, and what it wants ──────
|
||||
//
|
||||
// TEAMS.md §7.3, phase 9. This file answers three questions and makes no calls:
|
||||
// which Teams should have a voice channel, who should be able to enter it, and
|
||||
// what should happen to the ones that should not have it any more. The pass that
|
||||
// actually reaches Discord is `utils/teamVoiceSync.js`.
|
||||
//
|
||||
// **Access is a per-Team ROLE, always.** §7.3 specified per-member permission
|
||||
// overwrites with escalation to a role above ~90 members; the org lead settled on
|
||||
// roles always (2026-08-18). What that changes is not just a code path:
|
||||
//
|
||||
// - `voice_overwrite_max`, the escalation and the `overwrites`/`role` mode
|
||||
// transition all leave the design. There is no mode.
|
||||
// - The binding limit moves. Overwrites are capped per channel (~100), so the
|
||||
// old shape's ceiling was "one very large Team"; roles are capped per GUILD
|
||||
// (250), so the new shape's ceiling is "how many Teams have voice at all". A
|
||||
// limit on the number of Teams is a limit an operator has to be told about
|
||||
// before they hit it, which is why `roleCap` is in the admin payload and not
|
||||
// just in a `last_error` after a create failed.
|
||||
// - A role is visible on a member's Discord profile and an overwrite is not, so
|
||||
// membership of a Team becomes guild-visible. That is the trade the decision
|
||||
// bought and it is not reversible per-deployment.
|
||||
//
|
||||
// **Three things §7.3 named that this codebase does not have**, all settled the
|
||||
// same way — by asking the operator, because nothing in the data model can answer:
|
||||
// "the staff role" (see `teamVoiceSettings`), the parent category's identity, and
|
||||
// whether the bot can manage channels and roles at all.
|
||||
//
|
||||
// **Hidden Teams are never provisioned.** A Discord channel name is a
|
||||
// game-sourced string published outside the site, which is precisely §2.8's
|
||||
// concern — `utils/reservedNames.js` already names "and eventually a Discord
|
||||
// channel name" as one of the surfaces it protects. So the screen that suppresses
|
||||
// a Team's public page suppresses its channel too, and the interlock is free: the
|
||||
// gate is `hidden = 0` in one query rather than a second policy that could drift
|
||||
// from the first.
|
||||
|
||||
const voiceDb = require('./teamVoice.db')
|
||||
const settings = require('./teamVoiceSettings.model')
|
||||
|
||||
const PLATFORM = 'discord'
|
||||
const RESOURCE = 'voice'
|
||||
|
||||
// Discord's own limits on the two names this phase writes. Both are 100; kept as
|
||||
// two constants because they are two independent promises and a future platform
|
||||
// will not share them.
|
||||
const CHANNEL_NAME_MAX = 100
|
||||
const ROLE_NAME_MAX = 100
|
||||
|
||||
// How many role add/remove operations one pass hands the bot for one Team.
|
||||
//
|
||||
// A bound rather than "all of them", because each is its own Discord API call and
|
||||
// an unbounded first pass on a 300-member guild is a request that outlives its own
|
||||
// timeout — and a timeout is the one failure that leaves core not knowing what was
|
||||
// applied. Bounded passes converge instead: the remainder is reported and the next
|
||||
// pass takes the next slice.
|
||||
const MEMBER_OPS_PER_PASS = 50
|
||||
|
||||
// Control characters, as a named constant: a literal control byte in a source
|
||||
// file is invisible to every reader and to most diffs.
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g
|
||||
|
||||
/**
|
||||
* The name a Team's channel and role carry.
|
||||
*
|
||||
* `display_name_override` first, because §2.8.3 gives staff a way to change what
|
||||
* is DISPLAYED without touching identity, and a channel is a display surface. A
|
||||
* Team whose name staff rewrote must not keep publishing the original one to
|
||||
* Discord.
|
||||
*
|
||||
* The fallback is the Team's id, not its slug: a name that sanitises down to
|
||||
* nothing is a name made entirely of characters Discord will not take, and the
|
||||
* slug is derived from that same name, so it can be empty for the same reason.
|
||||
*/
|
||||
function displayName(team) {
|
||||
return sanitiseName(team.display_name_override || team.name) || `team-${team.team_id || team.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip what Discord will not carry, and nothing else.
|
||||
*
|
||||
* Deliberately not a slugifier. A voice channel keeps its spaces and its case —
|
||||
* unlike a text channel, which Discord lowercases and hyphenates itself — so
|
||||
* "The Silver Hand" should reach the guild as "The Silver Hand" and not as
|
||||
* "the-silver-hand". Control characters go because they can hide the rest of a
|
||||
* name; everything else a player can type is left alone, since core is a mirror of
|
||||
* the game and not an editor of it.
|
||||
*/
|
||||
function sanitiseName(value) {
|
||||
const text = String(value || '').replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim()
|
||||
return text.slice(0, Math.min(CHANNEL_NAME_MAX, ROLE_NAME_MAX))
|
||||
}
|
||||
|
||||
/** When a Team that stopped qualifying loses its channel. */
|
||||
function removeAfterFrom(graceDays, now = new Date()) {
|
||||
return new Date(now.getTime() + graceDays * 86400_000)
|
||||
}
|
||||
|
||||
const isExpired = (row, now = new Date()) => !!row
|
||||
&& !!row.remove_after
|
||||
&& new Date(row.remove_after).getTime() <= now.getTime()
|
||||
|
||||
/**
|
||||
* Everything one pass needs, resolved before it makes a single call.
|
||||
*
|
||||
* Returns `null` when voice is off, which is the answer on most deployments and
|
||||
* is not an error.
|
||||
*
|
||||
* **Turning the feature off does not tear anything down.** A toggle that deleted
|
||||
* guild structure would make "let me see what this does" destructive, and a voice
|
||||
* channel that outlives its setting is inert — nobody's access changes, the
|
||||
* channel simply stops being reconciled. The admin panel says how many are still
|
||||
* provisioned and offers to remove them one at a time, which is a decision an
|
||||
* operator makes rather than a side effect of a checkbox.
|
||||
*/
|
||||
async function plan({ now = new Date() } = {}) {
|
||||
const config = await settings.all()
|
||||
if (!config.enabled) return null
|
||||
|
||||
const [desired, holders] = await Promise.all([
|
||||
voiceDb.desiredTeams({ platform: PLATFORM, resource: RESOURCE, minMembers: config.minMembers }),
|
||||
voiceDb.holdersWithoutClaim({ platform: PLATFORM, resource: RESOURCE, minMembers: config.minMembers }),
|
||||
])
|
||||
|
||||
// A Team that qualifies again while inside its grace window appears in BOTH
|
||||
// queries only if the queries disagree, which they cannot — `desiredTeams`
|
||||
// requires it to qualify and `holdersWithoutClaim` requires it not to. So the
|
||||
// recovery case lands in `provision` with a row that still has `remove_after`
|
||||
// set, and clearing that stamp is what "cancel the removal" means.
|
||||
//
|
||||
// §7.3 promises no Discord call is made when a Team recovers. As built the
|
||||
// promise is narrower and truer: no DESTRUCTIVE call is made. A Team that
|
||||
// regained members has members to grant, and the ordinary membership diff is
|
||||
// what grants them — refusing to make any call at all would leave the people
|
||||
// who brought it back above the threshold outside the channel.
|
||||
const provision = desired.map((row) => ({
|
||||
team: row,
|
||||
name: displayName(row),
|
||||
hasRow: !!row.id,
|
||||
recovering: row.state === 'pending_removal',
|
||||
}))
|
||||
|
||||
const removals = []
|
||||
const scheduled = []
|
||||
for (const row of holders) {
|
||||
if (row.state !== 'pending_removal' || !row.remove_after) {
|
||||
scheduled.push({ team: row, removeAfter: removeAfterFrom(config.graceDays, now), reason: removalReason(row) })
|
||||
} else if (isExpired(row, now)) {
|
||||
removals.push({ team: row, reason: removalReason(row) })
|
||||
}
|
||||
}
|
||||
|
||||
return { config, provision, scheduled, removals }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a Team is losing its channel, in the words an operator reads in the panel.
|
||||
*
|
||||
* Three distinguishable causes, and they are worth distinguishing: "archived" is
|
||||
* expected, "below the threshold" is a Team shrinking, and "hidden" is a
|
||||
* moderation decision somebody made — which is the one where a surprised operator
|
||||
* would otherwise go looking for a bug.
|
||||
*/
|
||||
function removalReason(row) {
|
||||
if (row.team_status && row.team_status !== 'active') return 'archived'
|
||||
if (row.status && row.status !== 'active') return 'archived'
|
||||
if (row.hidden || row.team_hidden) return 'hidden'
|
||||
return 'below_threshold'
|
||||
}
|
||||
|
||||
/** The Discord ids a Team's role should be granted to — hop 3 of §2.6. */
|
||||
async function memberRefs(teamId) {
|
||||
return voiceDb.discordSubjectsFor(teamId)
|
||||
}
|
||||
|
||||
/** The admin panel's listing: every row, with the Team it belongs to. */
|
||||
async function list() {
|
||||
const rows = await voiceDb.listForPlatform(PLATFORM, RESOURCE)
|
||||
return rows.map((row) => ({
|
||||
teamId: row.team_id,
|
||||
teamName: row.display_name_override || row.team_name,
|
||||
teamSlug: row.team_slug,
|
||||
teamStatus: row.team_status,
|
||||
teamHidden: !!row.team_hidden,
|
||||
memberCount: row.member_count,
|
||||
linkedCount: row.linked_count,
|
||||
channelRef: row.external_ref,
|
||||
roleRef: row.role_ref,
|
||||
state: row.state,
|
||||
removeAfter: row.remove_after,
|
||||
lastError: row.last_error,
|
||||
syncedAt: row.synced_at,
|
||||
updatedAt: row.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getForTeam(teamId) {
|
||||
return voiceDb.getForTeam(teamId, PLATFORM, RESOURCE)
|
||||
}
|
||||
|
||||
/** Record the outcome of one Team's pass. */
|
||||
async function record({ teamId, channelRef, roleRef, state, removeAfter = null, lastError = null, syncedAt = null }) {
|
||||
return voiceDb.upsert({
|
||||
teamId,
|
||||
platform: PLATFORM,
|
||||
resource: RESOURCE,
|
||||
externalRef: channelRef,
|
||||
roleRef,
|
||||
state,
|
||||
removeAfter,
|
||||
lastError,
|
||||
syncedAt,
|
||||
})
|
||||
}
|
||||
|
||||
async function forget(teamId) {
|
||||
return voiceDb.remove(teamId, PLATFORM, RESOURCE)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PLATFORM,
|
||||
RESOURCE,
|
||||
CHANNEL_NAME_MAX,
|
||||
MEMBER_OPS_PER_PASS,
|
||||
displayName,
|
||||
sanitiseName,
|
||||
removeAfterFrom,
|
||||
isExpired,
|
||||
removalReason,
|
||||
plan,
|
||||
memberRefs,
|
||||
list,
|
||||
getForTeam,
|
||||
record,
|
||||
forget,
|
||||
}
|
||||
256
server/src/model/teams/teamVoiceSettings.model.js
Normal file
256
server/src/model/teams/teamVoiceSettings.model.js
Normal file
@@ -0,0 +1,256 @@
|
||||
// ── The operator's voice controls ──────────────────────────────────────────
|
||||
//
|
||||
// TEAMS.md §7.3, phase 9. Five `settings` keys, in their own file for the reason
|
||||
// `teamForumSettings` is: two of them are not ordinary keys. `teams_voice_enabled`
|
||||
// has a server-side precondition (the bot must actually be able to manage channels
|
||||
// and roles — §7.3 assumed it could and the tree has never checked), and
|
||||
// `teams_voice_category_ref` is written by the SERVER after the bot reports what
|
||||
// it created, not by the admin who is looking at the form.
|
||||
//
|
||||
// teams_voice_enabled '0' | '1' default '0' — off
|
||||
// teams_voice_min_members 1 … 10000 default 5
|
||||
// teams_voice_grace_days 0 … 90 default 7
|
||||
// teams_voice_category_ref a channel id absent until the bot makes one
|
||||
// teams_voice_staff_roles CSV of role ids empty by default
|
||||
//
|
||||
// **Every read fails closed**, the same bargain the forum settings take: a DB
|
||||
// fault reports voice off, which costs a pass that does nothing and is repeated
|
||||
// fifteen minutes later. Failing open would mean creating guild structure on the
|
||||
// strength of a query that did not answer.
|
||||
//
|
||||
// **`teams_voice_staff_roles` exists because "the staff role" does not.** §7.3
|
||||
// grants the staff role an overwrite on every Team channel; this codebase has no
|
||||
// staff-role concept at all — `guild_config` knows a news channel, a modlog
|
||||
// channel, an autorole and a filter allowlist, and none of them means "staff".
|
||||
// Guild administrators bypass channel overwrites anyway, so what is actually
|
||||
// missing is a way to let NON-admin staff in, and only the operator can say which
|
||||
// of their Discord roles those are. Empty is a legitimate and common answer.
|
||||
|
||||
const settingsDb = require('../settings/settings.db')
|
||||
|
||||
const ENABLED_KEY = 'teams_voice_enabled'
|
||||
const MIN_MEMBERS_KEY = 'teams_voice_min_members'
|
||||
const GRACE_DAYS_KEY = 'teams_voice_grace_days'
|
||||
const CATEGORY_KEY = 'teams_voice_category_ref'
|
||||
const STAFF_ROLES_KEY = 'teams_voice_staff_roles'
|
||||
|
||||
const MIN_MEMBERS_DEFAULT = 5
|
||||
const MIN_MEMBERS_MAX = 10000
|
||||
|
||||
const GRACE_DAYS_DEFAULT = 7
|
||||
const GRACE_DAYS_MAX = 90
|
||||
|
||||
// Discord's guild-wide role cap. It is the ceiling on how many Teams can have
|
||||
// voice at all, and it is here rather than in the bot because the admin panel has
|
||||
// to be able to say "you are at 231 of 250" BEFORE a create fails — §7.3's error
|
||||
// state per Team is a diagnosis, not a warning.
|
||||
//
|
||||
// The number is Discord's and core cannot read it; a guild that gets a different
|
||||
// one is a guild where this warns early, which is the harmless direction.
|
||||
const ROLE_CAP = 250
|
||||
|
||||
// The same shape `teamIntegration.model` validates a channel with. Core treats
|
||||
// every Discord id as opaque and only checks it could be one.
|
||||
const SNOWFLAKE_RE = /^[0-9]{5,32}$/
|
||||
|
||||
/** Is voice provisioning switched on? Fail closed. */
|
||||
async function enabled() {
|
||||
try {
|
||||
return String(await settingsDb.get(ENABLED_KEY)) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The membership threshold, counting every active member (org lead, 2026-08-18).
|
||||
*
|
||||
* Fails closed to the DEFAULT rather than to zero, unlike the forum's edit window:
|
||||
* zero here would mean "provision every Team including the one-person ones", which
|
||||
* is the expensive direction against a 250-role cap. The default is the
|
||||
* conservative answer, not the permissive one.
|
||||
*/
|
||||
async function minMembers() {
|
||||
try {
|
||||
const raw = await settingsDb.get(MIN_MEMBERS_KEY)
|
||||
if (raw == null || raw === '') return MIN_MEMBERS_DEFAULT
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n < 1 || n > MIN_MEMBERS_MAX) return MIN_MEMBERS_DEFAULT
|
||||
return Math.floor(n)
|
||||
} catch {
|
||||
return MIN_MEMBERS_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a Team keeps its channel after it stops qualifying (§7.3).
|
||||
*
|
||||
* `0` is legitimate and means "remove on the next pass" — an operator who would
|
||||
* rather not have stale channels lying about. A DB fault reports the default, so a
|
||||
* transient error can never turn the window off and delete something early; the
|
||||
* grace window's whole job is to not act in a hurry.
|
||||
*/
|
||||
async function graceDays() {
|
||||
try {
|
||||
const raw = await settingsDb.get(GRACE_DAYS_KEY)
|
||||
if (raw == null || raw === '') return GRACE_DAYS_DEFAULT
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n < 0 || n > GRACE_DAYS_MAX) return GRACE_DAYS_DEFAULT
|
||||
return Math.floor(n)
|
||||
} catch {
|
||||
return GRACE_DAYS_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent category every Team channel is created under, or null.
|
||||
*
|
||||
* Not an admin field. The bot creates the category on the first pass that needs
|
||||
* one and reports the id back; the server stores it here so the next pass reuses
|
||||
* it instead of making a second. An operator who wants a different category
|
||||
* deletes this value (or the category) and the next pass makes a fresh one — which
|
||||
* is why it is exposed read-only in the panel with a clear button rather than as a
|
||||
* text input somebody could point at a channel that is not a category.
|
||||
*/
|
||||
async function categoryRef() {
|
||||
try {
|
||||
const value = await settingsDb.get(CATEGORY_KEY)
|
||||
const text = String(value || '').trim()
|
||||
return SNOWFLAKE_RE.test(text) ? text : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function setCategoryRef(value, actorId = null) {
|
||||
const text = String(value || '').trim()
|
||||
if (text && !SNOWFLAKE_RE.test(text)) throw new Error('category ref must be a numeric channel id')
|
||||
return settingsDb.set(CATEGORY_KEY, text || null, actorId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles that see every Team voice channel, in addition to that Team's own.
|
||||
*
|
||||
* Stored as CSV for the same reason `filter_allow_roles` is — `settings.value` is
|
||||
* a VARCHAR and a JSON array in it buys nothing when the elements are numeric ids.
|
||||
* Unreadable entries are DROPPED rather than rejected on read: a hand-edited row
|
||||
* with one bad id should cost that id, not every staff grant on the deployment.
|
||||
*/
|
||||
async function staffRoles() {
|
||||
try {
|
||||
const raw = await settingsDb.get(STAFF_ROLES_KEY)
|
||||
return parseRoles(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoles(raw) {
|
||||
return String(raw || '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => SNOWFLAKE_RE.test(part))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an operator-supplied staff-role list on the way IN, where a typo can
|
||||
* still be reported to the person who made it.
|
||||
*
|
||||
* Rejected rather than filtered, the same call the bridge's event list makes: a
|
||||
* silently-dropped id is a settings screen that shows you saved something you did
|
||||
* not, and a role that was supposed to see every Team channel and does not is a
|
||||
* failure nobody would think to look for.
|
||||
*/
|
||||
function normaliseStaffRoles(input) {
|
||||
const parts = Array.isArray(input)
|
||||
? input
|
||||
: String(input == null ? '' : input).split(',')
|
||||
const seen = []
|
||||
for (const part of parts) {
|
||||
const id = String(part || '').trim()
|
||||
if (!id) continue
|
||||
if (!SNOWFLAKE_RE.test(id)) {
|
||||
const err = new Error(`not a role id: ${id}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (!seen.includes(id)) seen.push(id)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
/** Everything the reconciler and the admin panel both need, in one read. */
|
||||
async function all() {
|
||||
const [on, min, grace, category, staff] = await Promise.all([
|
||||
enabled(), minMembers(), graceDays(), categoryRef(), staffRoles(),
|
||||
])
|
||||
return {
|
||||
enabled: on,
|
||||
minMembers: min,
|
||||
graceDays: grace,
|
||||
categoryRef: category,
|
||||
staffRoles: staff,
|
||||
roleCap: ROLE_CAP,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an admin's save. Returns the settings as they now read, so the panel
|
||||
* renders what was stored rather than what was typed.
|
||||
*
|
||||
* The enable PRECONDITION is not here: it needs the bot, and a settings module
|
||||
* that reached across to another process to validate a write would be impossible
|
||||
* to test and surprising to read. The controller asks the bot and refuses, in the
|
||||
* same shape §7.2's acknowledgement refuses — 422 before the write, never a quiet
|
||||
* failure after it.
|
||||
*/
|
||||
async function save({ enabled: on, minMembers: min, graceDays: grace, staffRoles: staff }, actorId = null) {
|
||||
const next = {}
|
||||
if (on !== undefined) next[ENABLED_KEY] = on ? '1' : '0'
|
||||
if (min !== undefined) {
|
||||
const n = Number(min)
|
||||
if (!Number.isInteger(n) || n < 1 || n > MIN_MEMBERS_MAX) {
|
||||
const err = new Error(`minimum members must be between 1 and ${MIN_MEMBERS_MAX}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
next[MIN_MEMBERS_KEY] = String(n)
|
||||
}
|
||||
if (grace !== undefined) {
|
||||
const n = Number(grace)
|
||||
if (!Number.isInteger(n) || n < 0 || n > GRACE_DAYS_MAX) {
|
||||
const err = new Error(`the grace window must be between 0 and ${GRACE_DAYS_MAX} days`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
next[GRACE_DAYS_KEY] = String(n)
|
||||
}
|
||||
if (staff !== undefined) next[STAFF_ROLES_KEY] = normaliseStaffRoles(staff).join(',')
|
||||
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await settingsDb.set(key, value, actorId)
|
||||
}
|
||||
return all()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENABLED_KEY,
|
||||
MIN_MEMBERS_KEY,
|
||||
GRACE_DAYS_KEY,
|
||||
CATEGORY_KEY,
|
||||
STAFF_ROLES_KEY,
|
||||
MIN_MEMBERS_DEFAULT,
|
||||
GRACE_DAYS_DEFAULT,
|
||||
ROLE_CAP,
|
||||
enabled,
|
||||
minMembers,
|
||||
graceDays,
|
||||
categoryRef,
|
||||
setCategoryRef,
|
||||
staffRoles,
|
||||
parseRoles,
|
||||
normaliseStaffRoles,
|
||||
all,
|
||||
save,
|
||||
}
|
||||
312
server/src/model/teams/teams.db.js
Normal file
312
server/src/model/teams/teams.db.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// SQL for the Team tables. Raw parameterised mariadb, no ORM, per the layered
|
||||
// backend convention (router → controller → model → db).
|
||||
//
|
||||
// This file holds statements only. Every decision about WHETHER to write — the
|
||||
// four refusal gates, the quarantine, the rename rule — lives in the models above
|
||||
// it, because a gate expressed as a WHERE clause is a gate nobody can find.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── teams ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEAM_COLUMNS = `
|
||||
id, module_id, external_id, name, abbr, slug, status, meta,
|
||||
member_count, linked_count, online_count,
|
||||
hidden, hidden_reason, hidden_term, name_reviewed_at, display_name_override,
|
||||
roster_synced_at, members_empty_since,
|
||||
succeeded_by, created_at, archived_at, archived_reason`
|
||||
|
||||
/** Every ACTIVE team for a module — the set the reconciler diffs against. */
|
||||
async function activeByModule(moduleId) {
|
||||
return query(
|
||||
`SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND status = 'active' ORDER BY id`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ACTIVE team, whichever module owns it.
|
||||
*
|
||||
* For the READ side, which must not be keyed on a provider being registered. The
|
||||
* rows are core's and they outlive the module that filled them — a module
|
||||
* uninstalled or disabled leaves a projection that is unmaintained, not one that
|
||||
* stopped existing. Listing by provider made `/teams` empty while
|
||||
* `/teams/:slug/members` still answered in full, since the lookup goes by slug:
|
||||
* the index denied a Team that direct URLs served.
|
||||
*/
|
||||
async function allActive() {
|
||||
return query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE status = 'active' ORDER BY id`)
|
||||
}
|
||||
|
||||
/** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */
|
||||
async function findActive(moduleId, externalId) {
|
||||
const rows = await query(
|
||||
`SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND external_id = ? AND status = 'active'`,
|
||||
[moduleId, externalId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE id = ?`, [id])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** By slug, ACTIVE or ARCHIVED — an archived Team stays reachable at its old slug (§2.2). */
|
||||
async function findBySlug(slug) {
|
||||
const rows = await query(
|
||||
`SELECT ${TEAM_COLUMNS} FROM teams WHERE slug = ? ORDER BY (status = 'active') DESC, id DESC LIMIT 1`,
|
||||
[slug],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugs already taken, ACTIVE OR ARCHIVED.
|
||||
*
|
||||
* The unique key only constrains active rows, and this deliberately checks more
|
||||
* than the key does: §2.2 promises an archived Team stays readable at its old
|
||||
* slug, and handing that slug to a new Team would silently break every bookmark
|
||||
* and Discord link pointing at the old one.
|
||||
*/
|
||||
async function slugsLike(base) {
|
||||
const rows = await query('SELECT slug FROM teams WHERE slug = ? OR slug LIKE ?', [base, `${base}-%`])
|
||||
return rows.map((r) => r.slug)
|
||||
}
|
||||
|
||||
async function insertTeam({ moduleId, externalId, name, abbr, slug, meta, hidden, hiddenReason, hiddenTerm }) {
|
||||
const res = await query(
|
||||
`INSERT INTO teams (module_id, external_id, name, abbr, slug, meta, hidden, hidden_reason, hidden_term)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[moduleId, externalId, name, abbr, slug, meta == null ? null : JSON.stringify(meta),
|
||||
hidden ? 1 : 0, hiddenReason || null, hiddenTerm || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/** Update the mutable fields. `name` and `slug` are absent by design — §2.2 freezes both. */
|
||||
async function updateTeam(id, { abbr, meta }) {
|
||||
await query('UPDATE teams SET abbr = ?, meta = ? WHERE id = ?',
|
||||
[abbr, meta == null ? null : JSON.stringify(meta), id])
|
||||
}
|
||||
|
||||
async function archiveTeam(id, reason, succeededBy = null) {
|
||||
await query(
|
||||
`UPDATE teams SET status = 'archived', archived_at = NOW(), archived_reason = ?, succeeded_by = ?
|
||||
WHERE id = ? AND status = 'active'`,
|
||||
[reason, succeededBy, id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the three denormalised counts from the projection.
|
||||
*
|
||||
* Derived in one statement rather than incremented as rows change, so a missed
|
||||
* delta can never leave a count drifting from the table it summarises — the count
|
||||
* is only ever as wrong as the projection is.
|
||||
*/
|
||||
async function recount(teamId) {
|
||||
await query(
|
||||
`UPDATE teams t SET
|
||||
member_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active'),
|
||||
linked_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.user_id IS NOT NULL),
|
||||
online_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.online = 1)
|
||||
WHERE t.id = ?`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
// ── team_members ───────────────────────────────────────────────────────────
|
||||
|
||||
const MEMBER_COLUMNS = `
|
||||
team_id, member_key, display_name, user_id, is_leader, rank_label, online, status,
|
||||
first_seen_at, last_seen_at, departed_at`
|
||||
|
||||
async function membersByTeam(teamId, { includeDeparted = false } = {}) {
|
||||
return query(
|
||||
`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ?` +
|
||||
(includeDeparted ? '' : " AND status = 'active'") +
|
||||
' ORDER BY is_leader DESC, display_name, member_key',
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
async function memberKeys(teamId) {
|
||||
const rows = await query("SELECT member_key FROM team_members WHERE team_id = ? AND status = 'active'", [teamId])
|
||||
return rows.map((r) => r.member_key)
|
||||
}
|
||||
|
||||
async function findMember(teamId, memberKey) {
|
||||
const rows = await query(`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND member_key = ?`,
|
||||
[teamId, memberKey])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** The caller's ACTIVE membership of a team, or undefined. Path 1 of §2.5, and only path 1. */
|
||||
async function activeByUser(teamId, userId) {
|
||||
const rows = await query(
|
||||
`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'`,
|
||||
[teamId, userId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** Every ACTIVE membership a user holds, with the team joined on. */
|
||||
async function activeTeamsForUser(userId) {
|
||||
return query(
|
||||
`SELECT ${TEAM_COLUMNS.split(',').map((c) => `t.${c.trim()}`).join(', ')},
|
||||
m.member_key, m.is_leader, m.rank_label, m.display_name AS member_display_name
|
||||
FROM team_members m JOIN teams t ON t.id = m.team_id
|
||||
WHERE m.user_id = ? AND m.status = 'active' AND t.status = 'active'
|
||||
ORDER BY t.name`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or refresh one member row.
|
||||
*
|
||||
* `first_seen_at` is never overwritten, so a member who leaves and rejoins keeps
|
||||
* the date they first appeared; `status` returns to active on the same statement,
|
||||
* which is what makes a rejoin a revived row rather than a second one.
|
||||
*
|
||||
* **`is_leader` is set on INSERT only, and deliberately not on update.** Path 2 of
|
||||
* §2.5 is answered by `getTeamLeaders()`, not by the roster — two writers for one
|
||||
* column is how a refused leadership answer turns into a silent demotion, because
|
||||
* the roster would already have written `leader: false` before the authoritative
|
||||
* call was even made. Seeding it on insert means a Team whose leadership call is
|
||||
* failing is not leaderless from the start; after that, only setLeaders() moves it.
|
||||
*/
|
||||
async function upsertMember({ teamId, memberKey, displayName, userId, isLeader, rankLabel, online }) {
|
||||
await query(
|
||||
`INSERT INTO team_members (team_id, member_key, display_name, user_id, is_leader, rank_label, online)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
user_id = VALUES(user_id),
|
||||
rank_label = VALUES(rank_label),
|
||||
online = VALUES(online),
|
||||
status = 'active',
|
||||
departed_at = NULL,
|
||||
last_seen_at = NOW()`,
|
||||
[teamId, memberKey, displayName, userId, isLeader ? 1 : 0, rankLabel, online ? 1 : 0],
|
||||
)
|
||||
}
|
||||
|
||||
/** Soft-depart the named members. Rows are kept so history and rejoins survive. */
|
||||
async function markDeparted(teamId, memberKeys_) {
|
||||
if (!memberKeys_.length) return
|
||||
const holes = memberKeys_.map(() => '?').join(', ')
|
||||
await query(
|
||||
`UPDATE team_members SET status = 'departed', departed_at = NOW(), online = 0
|
||||
WHERE team_id = ? AND status = 'active' AND member_key IN (${holes})`,
|
||||
[teamId, ...memberKeys_],
|
||||
)
|
||||
}
|
||||
|
||||
/** Set is_leader for a whole team in one pass — the sync's path-2 write. */
|
||||
async function setLeaders(teamId, leaderKeys) {
|
||||
if (leaderKeys.length) {
|
||||
const holes = leaderKeys.map(() => '?').join(', ')
|
||||
await query(
|
||||
`UPDATE team_members SET is_leader = (member_key IN (${holes})) WHERE team_id = ?`,
|
||||
[...leaderKeys, teamId],
|
||||
)
|
||||
} else {
|
||||
await query('UPDATE team_members SET is_leader = 0 WHERE team_id = ?', [teamId])
|
||||
}
|
||||
}
|
||||
|
||||
async function setMemberLeader(teamId, memberKey, isLeader) {
|
||||
await query('UPDATE team_members SET is_leader = ? WHERE team_id = ? AND member_key = ?',
|
||||
[isLeader ? 1 : 0, teamId, memberKey])
|
||||
}
|
||||
|
||||
// ── team_sync_state ────────────────────────────────────────────────────────
|
||||
|
||||
async function syncState(moduleId) {
|
||||
const rows = await query(
|
||||
`SELECT module_id, last_attempt_at, last_success_at, consecutive_failures, last_error, pending_empty_since
|
||||
FROM team_sync_state WHERE module_id = ?`,
|
||||
[moduleId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
async function recordAttempt(moduleId) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, last_attempt_at) VALUES (?, NOW())
|
||||
ON DUPLICATE KEY UPDATE last_attempt_at = NOW()`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
async function recordFailure(moduleId, error) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, last_attempt_at, consecutive_failures, last_error)
|
||||
VALUES (?, NOW(), 1, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_attempt_at = NOW(),
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
last_error = VALUES(last_error)`,
|
||||
[moduleId, String(error || '').slice(0, 500)],
|
||||
)
|
||||
}
|
||||
|
||||
async function recordSuccess(moduleId) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, last_attempt_at, last_success_at, consecutive_failures, last_error)
|
||||
VALUES (?, NOW(), NOW(), 0, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_attempt_at = NOW(), last_success_at = NOW(), consecutive_failures = 0, last_error = NULL`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Bumped only when a roster was actually APPLIED — never on a refused call. */
|
||||
async function markRosterSynced(teamId) {
|
||||
await query('UPDATE teams SET roster_synced_at = NOW() WHERE id = ?', [teamId])
|
||||
}
|
||||
|
||||
/** §2.4 gate 4's per-Team quarantine. `since = null` clears it. */
|
||||
async function setMembersEmptySince(teamId, since) {
|
||||
await query('UPDATE teams SET members_empty_since = ? WHERE id = ?', [since, teamId])
|
||||
}
|
||||
|
||||
/** The §2.4 gate-2 quarantine. `since = null` clears it. */
|
||||
async function setPendingEmpty(moduleId, since) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, pending_empty_since) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE pending_empty_since = VALUES(pending_empty_since)`,
|
||||
[moduleId, since],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activeByModule,
|
||||
allActive,
|
||||
findActive,
|
||||
findById,
|
||||
findBySlug,
|
||||
slugsLike,
|
||||
insertTeam,
|
||||
updateTeam,
|
||||
archiveTeam,
|
||||
recount,
|
||||
markRosterSynced,
|
||||
setMembersEmptySince,
|
||||
membersByTeam,
|
||||
memberKeys,
|
||||
findMember,
|
||||
activeByUser,
|
||||
activeTeamsForUser,
|
||||
upsertMember,
|
||||
markDeparted,
|
||||
setLeaders,
|
||||
setMemberLeader,
|
||||
syncState,
|
||||
recordAttempt,
|
||||
recordFailure,
|
||||
recordSuccess,
|
||||
setPendingEmpty,
|
||||
}
|
||||
374
server/src/model/teams/teams.model.js
Normal file
374
server/src/model/teams/teams.model.js
Normal file
@@ -0,0 +1,374 @@
|
||||
// ── The Team read model ────────────────────────────────────────────────────
|
||||
//
|
||||
// What the three API tiers are allowed to see (TEAMS.md §2.11), assembled from
|
||||
// the projection, the resolver and the sync state.
|
||||
//
|
||||
// **Two rules shape every function here.**
|
||||
//
|
||||
// 1. *Hidden means absent from every public surface* (§2.8.3) — the index, the
|
||||
// lookup, the roster. Not archived, not deleted, and completely functional for
|
||||
// its own members. A hidden Team that 404s publicly but answers for a member
|
||||
// is the intended behaviour, not an inconsistency.
|
||||
//
|
||||
// 2. *Staleness is surfaced, never silent* (§2.4). Every public payload carries
|
||||
// `{ stale, lastSyncAt }`, so a page can say "roster last confirmed 14 minutes
|
||||
// ago" rather than presenting a stale roster as current. A projection nobody
|
||||
// can tell is stale is worse than one that is obviously old.
|
||||
//
|
||||
// The per-audience FIELD projection of a roster row is the module's, not core's
|
||||
// (§10.5, §3.3) — the visibility framework and its config are module-owned. This
|
||||
// phase serves a conservative core projection: a public roster carries in-game
|
||||
// display names and never a site account id or a game member key. The module's
|
||||
// rung-aware projection lands with the Team pages in phase 3.
|
||||
|
||||
const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const access = require('./teamAccess.model')
|
||||
const teamSync = require('./teamSync.model')
|
||||
|
||||
// Past this multiple of the poll interval a projection is reported stale. Two
|
||||
// intervals rather than one, so an ordinary late poll does not make every page
|
||||
// cry wolf — the threshold has to mean "something is wrong", not "a run is due".
|
||||
const STALE_INTERVALS = 2
|
||||
|
||||
/** The public shape of a Team. Deliberately small. */
|
||||
function publicTeam(row) {
|
||||
return {
|
||||
slug: row.slug,
|
||||
// What is DISPLAYED may have been overridden by staff; what the row IS never
|
||||
// changes (§2.2, §2.8.3). Public callers only ever see the former.
|
||||
name: row.display_name_override || row.name,
|
||||
abbr: row.abbr,
|
||||
memberCount: row.member_count,
|
||||
linkedCount: row.linked_count,
|
||||
onlineCount: row.online_count,
|
||||
meta: row.meta ?? null,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
rosterSyncedAt: row.roster_synced_at,
|
||||
...(row.status === 'archived' ? { archivedAt: row.archived_at, archivedReason: row.archived_reason } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public shape of a roster row.
|
||||
*
|
||||
* `member_key` and `user_id` are both withheld: the first is a game-internal
|
||||
* identifier and the second names a site account. `linked` answers the only
|
||||
* question a public page has — whether this character has an account behind it —
|
||||
* without publishing which one.
|
||||
*/
|
||||
function publicMember(row) {
|
||||
return {
|
||||
displayName: row.display_name,
|
||||
rankLabel: row.rank_label,
|
||||
isLeader: Boolean(row.is_leader),
|
||||
online: Boolean(row.online),
|
||||
linked: row.user_id != null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin shape: everything, including what a decision overrode. */
|
||||
function adminTeam(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
moduleId: row.module_id,
|
||||
externalId: row.external_id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
displayName: row.display_name_override || row.name,
|
||||
displayNameOverride: row.display_name_override,
|
||||
abbr: row.abbr,
|
||||
status: row.status,
|
||||
hidden: Boolean(row.hidden),
|
||||
hiddenReason: row.hidden_reason,
|
||||
hiddenTerm: row.hidden_term,
|
||||
nameReviewedAt: row.name_reviewed_at,
|
||||
memberCount: row.member_count,
|
||||
linkedCount: row.linked_count,
|
||||
onlineCount: row.online_count,
|
||||
rosterSyncedAt: row.roster_synced_at,
|
||||
membersEmptySince: row.members_empty_since,
|
||||
succeededBy: row.succeeded_by,
|
||||
createdAt: row.created_at,
|
||||
archivedAt: row.archived_at,
|
||||
archivedReason: row.archived_reason,
|
||||
meta: row.meta ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function adminMember(row) {
|
||||
return {
|
||||
memberKey: row.member_key,
|
||||
displayName: row.display_name,
|
||||
userId: row.user_id,
|
||||
rankLabel: row.rank_label,
|
||||
isLeader: Boolean(row.is_leader),
|
||||
isLeaderSynced: Boolean(row.is_leader_synced),
|
||||
leaderOverride: row.leader_override || null,
|
||||
online: Boolean(row.online),
|
||||
status: row.status,
|
||||
firstSeenAt: row.first_seen_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
departedAt: row.departed_at,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freshness, as every public payload reports it.
|
||||
*
|
||||
* With no provider registered there is nothing to be stale ABOUT, so this reports
|
||||
* `stale: false` and a null timestamp rather than "very stale" — a deployment
|
||||
* with no game module is not a broken one.
|
||||
*/
|
||||
async function syncStatus() {
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
if (!moduleId) return { stale: false, lastSyncAt: null, configured: false }
|
||||
|
||||
const [state, intervalS] = await Promise.all([
|
||||
teamsDb.syncState(moduleId),
|
||||
teamSync.intervalSeconds(),
|
||||
])
|
||||
const lastSyncAt = state ? state.last_success_at : null
|
||||
const ageS = lastSyncAt ? (Date.now() - new Date(lastSyncAt).getTime()) / 1000 : Infinity
|
||||
return {
|
||||
configured: true,
|
||||
lastSyncAt,
|
||||
// Never synced at all is stale: a page must not present an empty projection
|
||||
// as a confirmed empty shard.
|
||||
stale: ageS > intervalS * STALE_INTERVALS,
|
||||
consecutiveFailures: state ? state.consecutive_failures : 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listPublic({ limit = 50, offset = 0 } = {}) {
|
||||
// Every active Team, not just the registered provider's. The rows are core's
|
||||
// and they outlive the module that filled them: keying the index on a provider
|
||||
// made an uninstalled module's Teams vanish from /teams while
|
||||
// /teams/:slug/members still served them in full, because the lookup goes by
|
||||
// slug. `configured: false` is how a client learns the projection is no longer
|
||||
// being maintained -- an empty list would have said something untrue instead.
|
||||
const [rows, sync] = await Promise.all([teamsDb.allActive(), syncStatus()])
|
||||
const visible = rows.filter((r) => !r.hidden)
|
||||
return {
|
||||
teams: visible.slice(offset, offset + limit).map(publicTeam),
|
||||
total: visible.length,
|
||||
...sync,
|
||||
// What the `teams` nav feature flag resolves from (§3.5). True if a provider
|
||||
// is registered OR any Team exists — the second half matters because Team
|
||||
// rows outlive the module that filled them, and hiding the nav entry the
|
||||
// moment a module is uninstalled would make every existing Team page
|
||||
// unreachable from the site while still answering by URL.
|
||||
//
|
||||
// False only when there is nothing and no prospect of anything, which is
|
||||
// exactly the bare-core case the flag exists for: a link to a permanently
|
||||
// empty page is worse than no link.
|
||||
enabled: Boolean(sync.configured) || visible.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Team by slug, for a public caller.
|
||||
*
|
||||
* An ARCHIVED Team resolves rather than 404ing (§2.2): a bookmark or a Discord
|
||||
* link from before a rename must land somewhere that explains itself. A HIDDEN
|
||||
* one does not resolve at all — that is the difference between retired and
|
||||
* suppressed.
|
||||
*/
|
||||
async function getPublic(slug) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row || row.hidden) return null
|
||||
const sync = await syncStatus()
|
||||
const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null
|
||||
return {
|
||||
...publicTeam(row),
|
||||
// The three props the `team.overview` extension slot is declared with
|
||||
// (§3.4). A module's slot component runs in the browser and has to know
|
||||
// WHICH Team it is looking at, in its own vocabulary — `slug` is core's name
|
||||
// for it and resolves nothing on the module's side.
|
||||
//
|
||||
// On this route only, deliberately: the index has no slot and would
|
||||
// otherwise publish a module-internal identifier per row for nothing. None
|
||||
// of the three names a person — they are a core row id, a game-side group
|
||||
// id and a module name, and the identifiers §3.2 withholds (member keys,
|
||||
// site account ids) are not among them.
|
||||
id: row.id,
|
||||
externalId: row.external_id,
|
||||
moduleId: row.module_id,
|
||||
...sync,
|
||||
successor: successor && !successor.hidden
|
||||
? { slug: successor.slug, name: successor.display_name_override || successor.name }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Team's roster, projected for the caller's audience rung (§3.3).
|
||||
*
|
||||
* The ROW filter is the module's: it owns the visibility framework and its
|
||||
* configuration (§10.5), and core does not know what a rung is. The FIELD shape
|
||||
* stays core's — every row that survives goes through `publicMember`, which
|
||||
* withholds the member key and the user id whatever the module answers. So a
|
||||
* module can narrow what is published and cannot widen it, and core's "neither is
|
||||
* published" guarantee does not rest on every module's good behaviour.
|
||||
*
|
||||
* **A module that HAS a rung system and cannot answer withholds the roster.** That
|
||||
* is the one Team call where a refusal is not staleness: leaving a visibility
|
||||
* answer "alone" would publish the very rows the rungs exist to withhold. A
|
||||
* deployment with no module, or one whose module does not project at all, is a
|
||||
* different case entirely — nothing is being withheld there, so the roster is
|
||||
* served whole at core's public shape (`projects: false`).
|
||||
*/
|
||||
/**
|
||||
* One Team named the way the MODULE names it (§3.4 as amended).
|
||||
*
|
||||
* The lookup a module's page needs. A module holds its own identity for a Team —
|
||||
* a ServUO guild serial — and never core's row id or slug, deliberately: core's
|
||||
* identifiers are core-internal (§10.3), and handing them out is how a module
|
||||
* ends up storing them and then depending on them.
|
||||
*
|
||||
* Scoped to the naming module's OWN Teams. `module_id` comes from the path and is
|
||||
* matched, not trusted: it cannot be used to read another module's Team, which
|
||||
* matters because `external_id` is only unique within a module.
|
||||
*/
|
||||
async function getPublicByExternalId(moduleId, externalId) {
|
||||
const row = await teamsDb.findActive(moduleId, externalId)
|
||||
if (!row || row.hidden) return null
|
||||
return { ...publicTeam(row), id: row.id, externalId: row.external_id, moduleId: row.module_id, ...(await syncStatus()) }
|
||||
}
|
||||
|
||||
async function rosterPublic(slug, viewer = null) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row || row.hidden) return null
|
||||
const [members, sync] = await Promise.all([
|
||||
access.rosterWithOverrides(row.id),
|
||||
syncStatus(),
|
||||
])
|
||||
|
||||
// The module gets the rows as it supplied them — this is its own data coming
|
||||
// home — plus who is asking, which is all a rung decision needs.
|
||||
const answer = await teamProvider.projectRoster(row.external_id, members, viewer)
|
||||
let visible
|
||||
if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key))
|
||||
else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask
|
||||
else visible = members // nothing to fail closed ABOUT
|
||||
|
||||
return {
|
||||
members: visible.map(publicMember),
|
||||
...sync,
|
||||
rosterSyncedAt: row.roster_synced_at,
|
||||
// Stated rather than implied. An empty roster has three quite different
|
||||
// causes — a Team with no members, a rung that shows none, and a module that
|
||||
// could not be asked — and a page that cannot tell them apart will report the
|
||||
// last one as the first.
|
||||
projected: answer.ok,
|
||||
...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Player ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The caller's Teams — membership and grants — each with the REASON it is listed.
|
||||
*
|
||||
* The two are read from their own tables and merged here rather than by a query
|
||||
* that unions them, so the reason survives into the payload. `both` is a real
|
||||
* state and the UI needs it: a member who also holds a historical grant should
|
||||
* see membership as the current reason without the grant vanishing.
|
||||
*
|
||||
* A hidden Team IS listed here. Suppression is a public-surface rule; a member is
|
||||
* not a member of the public.
|
||||
*/
|
||||
async function listForUser(userId) {
|
||||
const memberships = await teamsDb.activeTeamsForUser(userId)
|
||||
const byId = new Map()
|
||||
|
||||
for (const row of memberships) {
|
||||
byId.set(row.id, { ...publicTeam(row), reason: 'membership', isLeader: Boolean(row.is_leader) })
|
||||
}
|
||||
|
||||
// Grants are per Team, so the visible set is walked rather than queried the
|
||||
// other way round; the population is small (a user's Teams), and it keeps path
|
||||
// 3's read on path 3's table.
|
||||
const all = await teamsDb.allActive()
|
||||
for (const row of all) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const resolved = await access.forumAccess(row.id, userId)
|
||||
if (!resolved.viaGrant) continue
|
||||
const existing = byId.get(row.id)
|
||||
if (existing) existing.reason = 'both'
|
||||
else byId.set(row.id, { ...publicTeam(row), reason: 'grant', isLeader: false })
|
||||
}
|
||||
|
||||
return { teams: [...byId.values()], ...(await syncStatus()) }
|
||||
}
|
||||
|
||||
/** The caller's own resolved access on one Team. */
|
||||
async function accessForUser(slug, userId) {
|
||||
const row = await teamsDb.findBySlug(slug)
|
||||
if (!row) return null
|
||||
const resolved = await access.forumAccess(row.id, userId)
|
||||
return { slug: row.slug, ...resolved }
|
||||
}
|
||||
|
||||
// ── Admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listAdmin({ includeArchived = false } = {}) {
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
const rows = await teamsDb.allActive()
|
||||
const sync = await syncStatus()
|
||||
const state = moduleId ? await teamsDb.syncState(moduleId) : null
|
||||
return {
|
||||
teams: rows.map(adminTeam),
|
||||
...sync,
|
||||
// Shown verbatim on Admin → Teams, including the last error: an operator
|
||||
// debugging a stale projection needs what the provider actually said.
|
||||
syncState: state
|
||||
? {
|
||||
moduleId: state.module_id,
|
||||
lastAttemptAt: state.last_attempt_at,
|
||||
lastSuccessAt: state.last_success_at,
|
||||
consecutiveFailures: state.consecutive_failures,
|
||||
lastError: state.last_error,
|
||||
pendingEmptySince: state.pending_empty_since,
|
||||
}
|
||||
: null,
|
||||
includeArchived,
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdmin(id) {
|
||||
const row = await teamsDb.findById(id)
|
||||
if (!row) return null
|
||||
const [members, grants, pending] = await Promise.all([
|
||||
access.rosterWithOverrides(row.id, { includeDeparted: true }),
|
||||
access.grantLedger(row.id),
|
||||
// eslint-disable-next-line global-require
|
||||
require('./teamModeration.model').pendingForTeam(row.id),
|
||||
])
|
||||
return {
|
||||
...adminTeam(row),
|
||||
members: members.map(adminMember),
|
||||
grants,
|
||||
pendingRequests: pending,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listPublic,
|
||||
getPublic,
|
||||
getPublicByExternalId,
|
||||
rosterPublic,
|
||||
listForUser,
|
||||
accessForUser,
|
||||
listAdmin,
|
||||
getAdmin,
|
||||
syncStatus,
|
||||
publicTeam,
|
||||
publicMember,
|
||||
adminTeam,
|
||||
adminMember,
|
||||
STALE_INTERVALS,
|
||||
}
|
||||
@@ -31,6 +31,33 @@ const log = require('../utils/logger')('modules')
|
||||
// such budget on purpose — it delays the listener binding, which is the feature.
|
||||
const SHUTDOWN_BUDGET_MS = 5000
|
||||
|
||||
/**
|
||||
* Nudge the bot to re-pull the slash-command set, from the two places that
|
||||
* actually change it in a live process: a boot, and an operator disabling a
|
||||
* module (which `remove` and `purge` both run through).
|
||||
*
|
||||
* Enabling and installing are deliberately NOT here — both ask for a restart
|
||||
* before the module runs, and a command whose handler is not registered yet is a
|
||||
* command that would answer "unknown". The nudge follows the state, not the
|
||||
* intention.
|
||||
*
|
||||
* Required lazily, and deliberately NOT awaited by either caller: the bot is
|
||||
* optional infrastructure, and neither a boot nor an operator's disable should
|
||||
* wait out `botInternalClient`'s 4s timeout because a bot container is wedged.
|
||||
* Nothing here throws — a failed nudge is a log line, and the bot re-pulls on its
|
||||
* next `ready` regardless.
|
||||
*/
|
||||
async function nudgeBot(why) {
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const bot = require('../utils/botInternalClient')
|
||||
const res = await bot.refreshCommands()
|
||||
if (!res.ok) log.info('bot did not take the slash-command nudge', { why, error: res.error })
|
||||
} catch (err) {
|
||||
log.warn('slash-command nudge failed', { why, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one database call for one module without letting it become everyone's
|
||||
* failure. Returns null on failure, having logged it.
|
||||
@@ -170,6 +197,23 @@ async function boot({ modules, model } = {}) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The Team reconciler's boot trigger (TEAMS.md §2.4), last — after every module
|
||||
// has started, because the provider is registered by a module and a module that
|
||||
// warms a cache in onBoot must be allowed to finish before it is asked anything.
|
||||
//
|
||||
// `safe` for the same reason every step above uses it: an unreachable provider
|
||||
// is a stale projection, never a site that will not start.
|
||||
// eslint-disable-next-line global-require
|
||||
await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start())
|
||||
|
||||
// Tell the bot the slash-command set may have moved (TEAMS.md §7.1).
|
||||
//
|
||||
// The bot pulls on its own `ready` too, so this is not the only path — it is
|
||||
// the path for the case `ready` does not cover: the APP restarting while the
|
||||
// bot stays connected, which is every ordinary redeploy. Without it, a module
|
||||
// added in that deploy has no command until someone restarts the bot.
|
||||
nudgeBot('boot')
|
||||
}
|
||||
|
||||
/** Reject if `fn`'s promise has not settled within `ms`. */
|
||||
@@ -271,6 +315,7 @@ async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {})
|
||||
}
|
||||
|
||||
await safe(`disabling module "${id}"`, () => rows.disable(id))
|
||||
nudgeBot(`disable:${id}`)
|
||||
return { stopped, error }
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ function buildCtx(id, moduleRoot) {
|
||||
const uploads = require('../router/v1/admin/imageUpload')
|
||||
const activity = require('../model/activity/activity.model')
|
||||
const users = require('../model/users/users.model')
|
||||
const teams = require('../model/teams/teamSync.model')
|
||||
const teamActivity = require('../model/teams/teamActivity.model')
|
||||
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -174,6 +176,37 @@ function buildCtx(id, moduleRoot) {
|
||||
// a place nobody looks. `list` stays core's: reading the log is the admin
|
||||
// panel's job, and it spans every actor.
|
||||
activity: { log: activity.log },
|
||||
// Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers.
|
||||
//
|
||||
// Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it
|
||||
// makes a membership change visible at once — and `reconcile` is a REQUEST,
|
||||
// debounced and never awaited, so a module cannot make its own call site slow
|
||||
// or turn a background failure into its own error. Correctness comes from the
|
||||
// reconciler either way; these only decide how soon.
|
||||
//
|
||||
// There is deliberately no reader here. A module answers questions about
|
||||
// Teams; it does not ask them. Every Team table is core-internal (§10.3), and
|
||||
// a `getTeamRoster` on ctx would be core offering to read back the module's
|
||||
// own answer — which is the module's data, in the module's own store.
|
||||
teams: {
|
||||
publish: (event) => teams.publish(event),
|
||||
reconcile: (opts) => teams.request(opts),
|
||||
// §4's activity feed (phase 3). `source` is bound to the CALLING module and
|
||||
// is never taken from the item — a module writes its own items, under its
|
||||
// own name, and items name their Team by the module's own `externalId`, so
|
||||
// there is no id a module could send that reaches another module's Team.
|
||||
//
|
||||
// Like `publish` and `reconcile` above, a failure here never reaches the
|
||||
// module: this is called from inside a game-event handler, and a storage
|
||||
// problem of core's must not become the module's control flow. A rejected
|
||||
// write is logged and the promise still resolves.
|
||||
activity: {
|
||||
push: (items) => teamActivity.push(id, items).then(
|
||||
(stored) => { void stored },
|
||||
(err) => { log.error('ctx.teams.activity.push failed', { module: id, message: err.message }) },
|
||||
),
|
||||
},
|
||||
},
|
||||
// One function, for one caller: the `admin.users.detail` slot router needs
|
||||
// the user its prefix names. Narrowed like `ctx.posts` — the users model
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
@@ -240,6 +273,26 @@ function buildApi(record) {
|
||||
record.staged.registerNotificationStreams(streams)
|
||||
},
|
||||
registerAnnounceLeg: record.staged.registerAnnounceLeg,
|
||||
// The Team provider (API 1.6.0, TEAMS.md §2.3). Unlike every registration
|
||||
// above, this one is core CALLING THE MODULE and waiting for an answer — the
|
||||
// same direction registerAnnounceLeg's dispatch already goes, which is why it
|
||||
// is modelled on it rather than invented. `once` because a module registering
|
||||
// twice means two answers to a question that has one.
|
||||
registerTeamProvider(provider) {
|
||||
once('registerTeamProvider')
|
||||
record.staged.registerTeamProvider(provider)
|
||||
},
|
||||
// Chat-platform slash commands (API 1.6.0, §7.1), live since phase 7. Like
|
||||
// registerTeamProvider above, the handler this stages is core CALLING THE
|
||||
// MODULE and waiting for an answer — but from further away than any other
|
||||
// member: the caller is a bot in another container, holding a Discord
|
||||
// interaction open on a deadline. `once` for the same reason the two
|
||||
// registries above take it — a second call is a module changing its mind
|
||||
// halfway through register(), not adding to what it already said.
|
||||
registerSlashCommands(commands) {
|
||||
once('registerSlashCommands')
|
||||
record.staged.registerSlashCommands(commands)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
@@ -58,6 +58,28 @@ const legs = new Map()
|
||||
// a collision with a name attached rather than a silently doubled side effect.
|
||||
const postHooks = new Map()
|
||||
|
||||
// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider
|
||||
// (API 1.6.0, TEAMS.md §2.3).
|
||||
//
|
||||
// A SINGLE value rather than a Map, unlike every registry above it, and that is
|
||||
// the contract: one provider per deployment. Teams have one authoritative source
|
||||
// by construction — two modules answering "what teams exist" would produce two
|
||||
// disjoint sets under one `teams` table with no rule for merging them, so a
|
||||
// second registration is a collision rather than an addition.
|
||||
let teamProvider = null
|
||||
|
||||
// command name → { owner, name, description, options, access, handler }. Slash
|
||||
// commands a registrant has published for the chat platform (API 1.6.0,
|
||||
// TEAMS.md §7.1).
|
||||
//
|
||||
// The DEFINITION and the HANDLER are registered together and the handler runs
|
||||
// HERE, in the website process; the bot pulls the definitions over the internal
|
||||
// API and owns every Discord-specific concern. That split is forced — the bot
|
||||
// container has no `modules` volume, so a module physically cannot put a handler
|
||||
// in it (§0.4) — and it is also the boundary we would pick anyway: a module
|
||||
// calling `interaction.deferReply()` would be a module holding a Discord handle.
|
||||
const slashCommands = new Map()
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
@@ -196,6 +218,31 @@ const announceLegIds = () => [...legs.keys()]
|
||||
/** One leg, or null. */
|
||||
const announceLeg = (leg) => legs.get(leg) || null
|
||||
|
||||
// ── Team provider (TEAMS.md §2.3) ──────────────────────────────────────────
|
||||
|
||||
/** The registered provider, or null when no module supplies one. */
|
||||
const registeredTeamProvider = () => teamProvider
|
||||
|
||||
/** Is there a Team provider at all? Read by the reconciler and the read API. */
|
||||
const hasTeamProvider = () => teamProvider !== null
|
||||
|
||||
// ── Slash commands (TEAMS.md §7.1) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every registered command WITHOUT its handler — what `/internal/commands`
|
||||
* serves to the bot.
|
||||
*
|
||||
* The handler is stripped rather than merely un-serialisable-and-ignored: this
|
||||
* is the object that crosses a process boundary, and the definition half is the
|
||||
* whole of what the bot is allowed to know. `owner` rides along so the bot can
|
||||
* name the module in a collision warning.
|
||||
*/
|
||||
const slashCommandDefinitions = () =>
|
||||
[...slashCommands.values()].map(({ handler, ...definition }) => definition)
|
||||
|
||||
/** One command, handler included. The dispatcher's lookup. */
|
||||
const slashCommand = (name) => slashCommands.get(name) || null
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -225,6 +272,166 @@ function checkLegShape(entry) {
|
||||
return { leg, label: label || leg, dispatch, classify }
|
||||
}
|
||||
|
||||
// Three methods are REQUIRED, with no optional half. A provider that could list
|
||||
// Teams but not their members would leave core holding Teams it can never
|
||||
// populate, and the reconciler has no sensible behaviour for that — it is not the
|
||||
// same as a call that fails, which is staleness and already handled (§2.4). A
|
||||
// module unable to answer one of the three answers `{ ok: false }` at call time.
|
||||
//
|
||||
// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an
|
||||
// audience model, and a module with no rung system of its own has no opinion to
|
||||
// express. Omitting it means core serves rosters at its own public shape;
|
||||
// implementing it means core fails CLOSED when the call cannot be made, so this
|
||||
// is a member to add deliberately rather than by habit.
|
||||
//
|
||||
// `pageUrlTemplate` is the fifth, also OPTIONAL, and is data rather than a method
|
||||
// — see its own comment below. A module that omits it costs its deployment
|
||||
// clickable links in Team notification email and nothing else.
|
||||
//
|
||||
// The copy is explicit rather than a spread: this object is what core calls, so
|
||||
// anything not named here is not part of the contract and must not survive
|
||||
// registration. A method that silently rode along would look implemented from the
|
||||
// module's side and be invisible from core's.
|
||||
function checkTeamProviderShape(entry) {
|
||||
const provider = entry || {}
|
||||
const out = {}
|
||||
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
|
||||
if (typeof provider[name] !== 'function') {
|
||||
throw new Error(`registerTeamProvider: ${name}() is missing or not a function`)
|
||||
}
|
||||
out[name] = provider[name]
|
||||
}
|
||||
if (provider.projectRoster !== undefined) {
|
||||
if (typeof provider.projectRoster !== 'function') {
|
||||
throw new Error('registerTeamProvider: projectRoster must be a function if present')
|
||||
}
|
||||
out.projectRoster = provider.projectRoster
|
||||
}
|
||||
if (provider.pageUrlTemplate !== undefined) {
|
||||
out.pageUrlTemplate = checkPageUrlTemplate(provider.pageUrlTemplate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
|
||||
//
|
||||
// **Why a module has to supply this at all.** Teams are a contract primitive with
|
||||
// no core surface: core owns the tables and the access rules, and the MODULE owns
|
||||
// the page, because core does not own the word for a Team. That is settled and
|
||||
// right — but it leaves core unable to write a link to one, and a notification
|
||||
// email that cannot link to the thread it is about is most of the way to useless.
|
||||
// So the module that owns the page says where it is.
|
||||
//
|
||||
// **A template, not a callback.** Core substitutes `{externalId}` and `{slug}`
|
||||
// into a relative path and does nothing else with it. A function would be a
|
||||
// module hook on the mail path — one more thing that can hang or throw between a
|
||||
// forum reply and the mail about it — to produce a string that never varies.
|
||||
//
|
||||
// Validated hard, because the output goes into an email as a link. Relative only:
|
||||
// a template naming its own host would let a module redirect the site's outbound
|
||||
// mail somewhere else, and there is no reason for one to.
|
||||
// One leading slash, and the second character may not be another. `//evil.test/x`
|
||||
// passes an "is it rooted" check and is a PROTOCOL-RELATIVE url — core prefixing
|
||||
// its own base makes it harmless today, but a template is a string that ends up
|
||||
// in an href sooner or later, and this is a character class rather than a
|
||||
// judgement call about who concatenates it.
|
||||
const PAGE_URL_TEMPLATE = /^\/(?!\/)[A-Za-z0-9\-._~/{}]*$/
|
||||
|
||||
function checkPageUrlTemplate(value) {
|
||||
if (typeof value !== 'string' || !PAGE_URL_TEMPLATE.test(value)) {
|
||||
throw new Error(`registerTeamProvider: pageUrlTemplate must be a relative path, got "${value}"`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// A slash command's name and description are validated HERE and not only at the
|
||||
// bot, for a reason worth stating: the bot registers the whole set in a single
|
||||
// `REST.put(applicationGuildCommands)`, so ONE malformed definition is rejected
|
||||
// by Discord as a batch and takes every other command down with it — including
|
||||
// the bot's own. A definition that cannot be registered must therefore fail at
|
||||
// `register()`, where it belongs to a module that can be named and marked
|
||||
// failed, rather than at the next `ready` where it looks like the bot is broken.
|
||||
//
|
||||
// **Commands are NOT namespaced under their owner, unlike every other id in this
|
||||
// file.** Discord's name grammar has no `.` in it, so `uo.guild` is unregistrable
|
||||
// and the prefix rule cannot be expressed. Collisions are caught by first-come
|
||||
// instead, with the holder named — and the bot resolves the one collision core
|
||||
// cannot see (a pulled name against its own built-ins) in the module's disfavour.
|
||||
const SLASH_NAME = /^[a-z0-9_-]{1,32}$/
|
||||
const SLASH_ACCESS = ['everyone', 'linked', 'staff']
|
||||
|
||||
// §7.1.1: `string | integer | boolean | user`, and deliberately nothing else. No
|
||||
// subcommand groups, autocomplete, attachments, modals or component
|
||||
// interactions. Those are exactly the features whose semantics do not survive a
|
||||
// second platform, and admitting one here is how Discord specifics leak into a
|
||||
// platform-agnostic registration API by accident.
|
||||
const SLASH_OPTION_TYPES = ['string', 'integer', 'boolean', 'user']
|
||||
|
||||
function checkSlashOption(command, option) {
|
||||
const { name, type, description, required, choices } = option || {}
|
||||
const where = `registerSlashCommands: ${command}`
|
||||
if (!SLASH_NAME.test(name || '')) throw new Error(`${where}: bad option name "${name}"`)
|
||||
if (!SLASH_OPTION_TYPES.includes(type)) {
|
||||
throw new Error(`${where}: option "${name}" has unsupported type "${type}" (§7.1.1)`)
|
||||
}
|
||||
if (!description || description.length > 100) {
|
||||
throw new Error(`${where}: option "${name}" needs a description of 1-100 characters`)
|
||||
}
|
||||
const out = { name, type, description, required: Boolean(required) }
|
||||
if (choices !== undefined) {
|
||||
if (!Array.isArray(choices) || !choices.length) {
|
||||
throw new Error(`${where}: option "${name}" has an empty choices list`)
|
||||
}
|
||||
// Only the two option types Discord itself allows choices on. `boolean` is
|
||||
// already a two-value choice and `user` is a picker; a choices list on
|
||||
// either is a misunderstanding worth failing rather than dropping.
|
||||
if (type !== 'string' && type !== 'integer') {
|
||||
throw new Error(`${where}: option "${name}" is ${type}; choices need string or integer`)
|
||||
}
|
||||
out.choices = choices.map((c) => {
|
||||
if (!c || !c.name || c.value === undefined) {
|
||||
throw new Error(`${where}: option "${name}" has a choice with no name/value`)
|
||||
}
|
||||
return { name: String(c.name), value: c.value }
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerSlashCommands([{ name, description, options, access, handler }])`.
|
||||
*
|
||||
* `access` is enforced TWICE and this copy is not the gate: the bot sets
|
||||
* Discord-side default member permissions from it where it can, and the
|
||||
* dispatcher re-checks it on every call. Client-side is about not advertising a
|
||||
* dead end; the server is the boundary — the same principle the nav follows.
|
||||
*/
|
||||
function checkSlashCommandShape(entry) {
|
||||
const { name, description, options, access, handler } = entry || {}
|
||||
if (!SLASH_NAME.test(name || '')) {
|
||||
throw new Error(`registerSlashCommands: bad command name "${name}" (lowercase, 1-32, no dots)`)
|
||||
}
|
||||
if (!description || description.length > 100) {
|
||||
throw new Error(`registerSlashCommands: ${name} needs a description of 1-100 characters`)
|
||||
}
|
||||
if (typeof handler !== 'function') throw new Error(`registerSlashCommands: ${name} has no handler()`)
|
||||
if (access !== undefined && !SLASH_ACCESS.includes(access)) {
|
||||
throw new Error(`registerSlashCommands: ${name} has unknown access "${access}"`)
|
||||
}
|
||||
if (options !== undefined && !Array.isArray(options)) {
|
||||
throw new Error(`registerSlashCommands: ${name} options must be an array`)
|
||||
}
|
||||
const checked = (options || []).map((o) => checkSlashOption(name, o))
|
||||
// Discord rejects a definition that puts an optional option before a required
|
||||
// one, and does it for the whole batch. Sorting silently would change what the
|
||||
// module wrote; this is the module's own ordering bug and it gets its name.
|
||||
const firstOptional = checked.findIndex((o) => !o.required)
|
||||
if (firstOptional !== -1 && checked.slice(firstOptional).some((o) => o.required)) {
|
||||
throw new Error(`registerSlashCommands: ${name} lists a required option after an optional one`)
|
||||
}
|
||||
return { name, description, options: checked, access: access || 'everyone', handler }
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
||||
* required. A registration with neither is a subscription that can never fire,
|
||||
@@ -265,7 +472,9 @@ function checkExtensionShape(slot, router, specFile) {
|
||||
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] }
|
||||
const staged = {
|
||||
owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [], slashCommands: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
@@ -281,6 +490,13 @@ function stage(owner) {
|
||||
registerPostHook(entry) {
|
||||
staged.postHooks.push(checkPostHookShape(entry))
|
||||
},
|
||||
registerTeamProvider(entry) {
|
||||
staged.teamProviders.push(checkTeamProviderShape(entry))
|
||||
},
|
||||
registerSlashCommands(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerSlashCommands: expected an array')
|
||||
for (const e of entries) staged.slashCommands.push(checkSlashCommandShape(e))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +509,15 @@ function stage(owner) {
|
||||
* PR 2 learned to protect (mounting inside the scan loop made every collision
|
||||
* look like it was with core).
|
||||
*/
|
||||
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) {
|
||||
function apply({
|
||||
owner,
|
||||
streams: newStreams,
|
||||
legs: newLegs,
|
||||
extensions: newExtensions,
|
||||
postHooks: newPostHooks = [],
|
||||
teamProviders: newTeamProviders = [],
|
||||
slashCommands: newSlashCommands = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
for (const s of newStreams) {
|
||||
@@ -332,6 +556,19 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
|
||||
throw new Error(`"${owner}" already registered a post hook`)
|
||||
}
|
||||
|
||||
if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`)
|
||||
if (newTeamProviders.length && teamProvider) {
|
||||
throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
|
||||
}
|
||||
|
||||
const seenCommands = new Set()
|
||||
for (const c of newSlashCommands) {
|
||||
const held = slashCommands.get(c.name)
|
||||
if (held) throw new Error(`slash command "/${c.name}" is already registered by "${held.owner}"`)
|
||||
if (seenCommands.has(c.name)) throw new Error(`slash command "/${c.name}" registered twice`)
|
||||
seenCommands.add(c.name)
|
||||
}
|
||||
|
||||
// ── commit — nothing below can fail ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
@@ -345,6 +582,8 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
|
||||
entry.router.use(x.router)
|
||||
}
|
||||
for (const h of newPostHooks) postHooks.set(owner, h)
|
||||
for (const p of newTeamProviders) teamProvider = { owner, ...p }
|
||||
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
@@ -410,6 +649,8 @@ function _reset() {
|
||||
streamOwners.clear()
|
||||
legs.clear()
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
slashCommands.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -427,6 +668,10 @@ module.exports = {
|
||||
announceLeg,
|
||||
postHookEntries,
|
||||
dispatchPostHook,
|
||||
registeredTeamProvider,
|
||||
hasTeamProvider,
|
||||
slashCommandDefinitions,
|
||||
slashCommand,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
// 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.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so
|
||||
// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`,
|
||||
// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`,
|
||||
// `ctx.teams.activity.push(items)`, `api.registerSlashCommands([...])`, and the
|
||||
// client slots `team.overview` / `team.member.row`. module-uo's `coreApi:
|
||||
// "^1.3.0"` still resolves.
|
||||
//
|
||||
// **The number covers the whole surface; the members arrived by phase, and all of
|
||||
// them have now arrived.** `activity.push` landed with the Team activity feed
|
||||
// (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
|
||||
// phase 7); until each did, it was present and THREW rather than being absent or,
|
||||
// worse, silently accepting data into a table that did not exist. Nothing in
|
||||
// 1.6.0 throws any more.
|
||||
//
|
||||
// 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that
|
||||
// renders the page body wrapper core's own pages write by hand (MODULE_API.md
|
||||
// §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major
|
||||
@@ -44,6 +58,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.5.0'
|
||||
const MODULE_API_VERSION = '1.6.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
@@ -589,8 +590,64 @@ async function updateSettings(req, res) {
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
||||
}
|
||||
// The Team-forum controls (TEAMS.md §5.5). Two enum keys and one PRECONDITION —
|
||||
// the only key on this endpoint whose write depends on something other than its
|
||||
// own value. `acknowledge` is a request field, not a setting: it is consumed
|
||||
// here and never stored, because what gets stored is the text VERSION the
|
||||
// operator accepted, written by recordAck() below.
|
||||
if (forumSettings.ENABLED_KEY in updates) {
|
||||
const v = updates[forumSettings.ENABLED_KEY]
|
||||
if (v !== '0' && v !== '1' && v !== true && v !== false) {
|
||||
return res.status(400).json({ message: 'Invalid teams_forums_enabled value' })
|
||||
}
|
||||
updates[forumSettings.ENABLED_KEY] = v === true || v === '1' ? '1' : '0'
|
||||
}
|
||||
const nextImageMode = updates[forumSettings.IMAGES_KEY]
|
||||
if (forumSettings.IMAGES_KEY in updates) {
|
||||
if (!forumSettings.IMAGE_MODES.includes(nextImageMode)) {
|
||||
return res.status(400).json({ message: 'Invalid teams_forum_images value' })
|
||||
}
|
||||
// THE GATE (§5.5.5). Server-side, and rejected 400 with the admin UI's
|
||||
// checkbox bypassed — a checkbox is how the gate is presented, never the gate.
|
||||
const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
|
||||
if (!gate.ok) return res.status(gate.status).json({ message: gate.error })
|
||||
}
|
||||
if (forumSettings.EDIT_WINDOW_KEY in updates) {
|
||||
// The post edit window (phase 5). An ordinary key with a range, validated
|
||||
// here rather than left to the model's read-side clamp: a read that silently
|
||||
// coerces a nonsense value back to the default is right for a hand-edited
|
||||
// row and wrong for an admin who just typed one, who should be told.
|
||||
const raw = updates[forumSettings.EDIT_WINDOW_KEY]
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < 0 || n > forumSettings.EDIT_WINDOW_MAX) {
|
||||
return res.status(400).json({
|
||||
message: `teams_forum_edit_window_minutes must be a whole number of minutes between 0 and ${forumSettings.EDIT_WINDOW_MAX}`,
|
||||
})
|
||||
}
|
||||
updates[forumSettings.EDIT_WINDOW_KEY] = String(n)
|
||||
}
|
||||
{
|
||||
// The stale-acknowledgement lock: a reworded notice freezes the forum
|
||||
// settings until it is re-given, and does NOT turn uploads off (§5.5.5).
|
||||
const writable = await forumSettings.assertSettingsWritable(Object.keys(updates), req.body.acknowledge)
|
||||
if (!writable.ok) return res.status(writable.status).json({ message: writable.error })
|
||||
}
|
||||
const acknowledging = String(req.body.acknowledge ?? '') === forumSettings.ACK_VERSION
|
||||
delete updates.acknowledge
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
if (acknowledging && (nextImageMode === 'uploads' || forumSettings.IMAGES_KEY in updates)) {
|
||||
// Recorded, not merely displayed: `updated_by`/`updated_at` come from the
|
||||
// settings schema, and the activity_log row puts it in the staff audit trail
|
||||
// with the acting admin's IP alongside every other consequential action.
|
||||
await forumSettings.recordAck(req.user.id)
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.uploads.acknowledged',
|
||||
detail: `${req.user.username} (#${req.user.id}) acknowledged the image-upload notice `
|
||||
+ `(version ${forumSettings.ACK_VERSION})`,
|
||||
})
|
||||
}
|
||||
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||
// to say so, or the favicon an admin just uploaded appears only after the
|
||||
|
||||
@@ -31,6 +31,8 @@ const emailRouter = require('./email.router')
|
||||
const discordBotRouter = require('./discordBot.router')
|
||||
const settingsRouter = require('./settings.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamsVoiceRouter = require('./teamsVoice.router')
|
||||
const dashboardRouter = require('./dashboard.router')
|
||||
|
||||
const adminRouter = express.Router()
|
||||
@@ -79,6 +81,21 @@ adminRouter.use('/settings', settingsRouter)
|
||||
// here alongside the other configuration capabilities, and admin-only per route
|
||||
// rather than at this line, so the gate sits next to what it is guarding.
|
||||
adminRouter.use('/modules', modulesRouter)
|
||||
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
|
||||
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
|
||||
// per request inside the controller, not per route — a moderator may call them,
|
||||
// and calling them files a request rather than applying one (TEAMS.md §2.9).
|
||||
// Voice channels (TEAMS.md §7.3, phase 9) are mounted at the more specific prefix
|
||||
// FIRST, so /teams/voice/* never reaches the teams router's `/:id`.
|
||||
//
|
||||
// They live out here rather than inside `teams.router.js` beside the bridge they
|
||||
// belong with, for a mechanical reason worth recording: that file sits exactly at
|
||||
// swagger-autogen's per-file limit. At twenty `teamsRouter.*` statements
|
||||
// `npm run swagger` dies with "invalid array length — heap out of memory"; at
|
||||
// nineteen it generates. One more statement of any shape tips it, a mount
|
||||
// included, so the mount is here and the file keeps its nineteen.
|
||||
adminRouter.use('/teams/voice', teamsVoiceRouter)
|
||||
adminRouter.use('/teams', teamsRouter)
|
||||
|
||||
// The two singletons that own no path segment of their own: GET /dashboard and
|
||||
// PUT /site-mode. Mounted at the group root, last, exactly where the residual
|
||||
|
||||
@@ -6,6 +6,7 @@ const moderation = require('../../../model/moderation/moderation.model')
|
||||
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
||||
const botInternalClient = require('../../../utils/botInternalClient')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
@@ -295,6 +296,68 @@ async function getUserAppeals(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
|
||||
//
|
||||
// Mounted here rather than under Teams, and that placement is the design: a
|
||||
// staffer working a queue should have one place to work, and a report about a
|
||||
// forum post is the same job as a report about anything else. `target_type` is a
|
||||
// VARCHAR precisely so the next consumer — a wiki page, a news comment — arrives
|
||||
// as a value in this same queue and not as a second screen.
|
||||
//
|
||||
// **This is the only view of the queue that exists.** Team leaders have no
|
||||
// report-facing surface at all, because the gap §5.6 closes is that a Team's
|
||||
// leaders are exactly the people who will not report their own Team. Org lead,
|
||||
// 2026-08-18: reports are site administration only.
|
||||
|
||||
async function getContentReports(req, res) {
|
||||
try {
|
||||
const { limit, offset } = pageParams(req)
|
||||
const status = typeof req.query.status === 'string' ? req.query.status : undefined
|
||||
if (status && status !== 'all' && !contentReports.STATUSES.includes(status)) {
|
||||
return res.status(400).json({ message: 'Unknown report status' })
|
||||
}
|
||||
const teamId = Number(req.query.teamId) || undefined
|
||||
return res.json({
|
||||
reports: await contentReports.queue({ status, teamId, limit, offset }),
|
||||
openCount: await contentReports.openCount(),
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('getContentReports failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a report along the queue.
|
||||
*
|
||||
* Every transition writes `activity_log`, including `dismissed` — especially
|
||||
* `dismissed`. A queue where acting is audited and declining to act is not is one
|
||||
* where the cheapest way to make a report disappear leaves no trace, and the
|
||||
* reports most worth auditing are exactly the ones somebody wanted gone.
|
||||
*/
|
||||
async function handleContentReport(req, res) {
|
||||
try {
|
||||
const result = await contentReports.handle({
|
||||
id: Number(req.params.id),
|
||||
actor: req.user,
|
||||
status: req.body.status,
|
||||
note: req.body.note,
|
||||
})
|
||||
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'moderation.report.handle',
|
||||
detail: `${req.user.username} (#${req.user.id}) set report #${req.params.id} to ${req.body.status}`
|
||||
+ `${req.body.note ? `: "${req.body.note}"` : ''}`,
|
||||
})
|
||||
return res.json(result.report)
|
||||
} catch (err) {
|
||||
log.error('handleContentReport failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSummary,
|
||||
getRecent,
|
||||
@@ -311,4 +374,6 @@ module.exports = {
|
||||
claimAppeal,
|
||||
resolveAppeal,
|
||||
getUserAppeals,
|
||||
getContentReports,
|
||||
handleContentReport,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Admin · Moderation — the moderation dashboard and the appeals queue.
|
||||
// Admin · Moderation — the moderation dashboard, the appeals queue and the
|
||||
// member-raised content-report queue (TEAMS.md §5.6).
|
||||
//
|
||||
// Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's
|
||||
@@ -16,6 +17,7 @@ const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const moderation = require('./moderation.controller')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
@@ -171,4 +173,34 @@ moderationRouter.get(
|
||||
moderation.getUserAppeals,
|
||||
)
|
||||
|
||||
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
|
||||
// Beside appeals rather than under Teams: a staffer working a queue should have
|
||||
// one place to work. There is no leader-facing counterpart to these two routes
|
||||
// and there is not meant to be — see the controller.
|
||||
moderationRouter.get(
|
||||
'/reports',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'The member-raised content report queue'
|
||||
// #swagger.description = 'Defaults to the open work (`open` + `reviewing`); filter with ?status=<open|reviewing|actioned|dismissed|all> and ?teamId=, page with ?limit&offset. Each row carries its TARGET already resolved — a post’s excerpt and author, a thread’s title, or an upload’s uploader, byte size and SNIFFED mimetype — so triage never means hunting for what was reported. A target that has since been hard-deleted comes back as null and the report still lists: "somebody reported this and by the time we looked it was gone" is a fact worth seeing.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The queue', content: { "application/json": { schema: { type: 'object', properties: { reports: { type: 'array', items: { $ref: "#/components/schemas/ContentReport" } }, openCount: { type: 'integer' } } } } } } */
|
||||
moderation.getContentReports,
|
||||
)
|
||||
moderationRouter.post(
|
||||
'/reports/:id/handle',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Claim, action or dismiss a content report'
|
||||
// #swagger.description = 'Handling a report is bookkeeping about the report, not moderation of the content — acting on the content itself is the ordinary forum moderation route, or a site-wide sanction against the account. Every transition writes activity_log, `dismissed` included: a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['open','reviewing','actioned','dismissed'] }, note: { type: 'string', maxLength: 500 } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The updated report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Report not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('status').isIn(contentReports.STATUSES),
|
||||
body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
moderation.handleContentReport,
|
||||
)
|
||||
|
||||
module.exports = moderationRouter
|
||||
|
||||
490
server/src/router/v1/admin/teams.controller.js
Normal file
490
server/src/router/v1/admin/teams.controller.js
Normal file
@@ -0,0 +1,490 @@
|
||||
// Admin · Teams — the staff surface (TEAMS.md §2.11).
|
||||
//
|
||||
// The role split inside this file is the §2.9 gate, and it is enforced HERE
|
||||
// rather than in the router, because it is not a matter of which routes a role
|
||||
// may call: a moderator may call all of them, and three of them mean something
|
||||
// different when they do. `requestOrApply` is what decides, from the caller's
|
||||
// live role, whether an action applies or is filed for approval.
|
||||
|
||||
const teams = require('../../../model/teams/teams.model')
|
||||
const moderation = require('../../../model/teams/teamModeration.model')
|
||||
const access = require('../../../model/teams/teamAccess.model')
|
||||
const teamSync = require('../../../model/teams/teamSync.model')
|
||||
const teamsDb = require('../../../model/teams/teams.db')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const forum = require('../../../model/teams/teamForum.model')
|
||||
const forumDb = require('../../../model/teams/teamForum.db')
|
||||
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const integration = require('../../../model/teams/teamIntegration.model')
|
||||
const voice = require('../../../model/teams/teamVoice.model')
|
||||
const voiceSettings = require('../../../model/teams/teamVoiceSettings.model')
|
||||
const voiceSync = require('../../../utils/teamVoiceSync')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
const fail = (res, err, what) => {
|
||||
log.error(`admin teams: ${what} failed`, { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
/** Translate a model result's { ok, status, error } into a response. */
|
||||
const send = (res, result, body = { ok: true }) =>
|
||||
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
|
||||
|
||||
async function listTeams(req, res) {
|
||||
try {
|
||||
return res.json(await teams.listAdmin({ includeArchived: req.query.archived === '1' }))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'list')
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeam(req, res) {
|
||||
try {
|
||||
const team = await teams.getAdmin(Number(req.params.id))
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json(team)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'get')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator's escape hatch.
|
||||
*
|
||||
* Awaited rather than fire-and-forget: someone who pressed a button is owed the
|
||||
* outcome, including the provider's error when it refused. `ctx.teams.reconcile()`
|
||||
* is the debounced, unawaited path — this is not that.
|
||||
*/
|
||||
async function resync(req, res) {
|
||||
try {
|
||||
const result = await teamSync.reconcileNow('admin')
|
||||
await activity.log({ req, action: 'team.resync', detail: `${req.user.username} (#${req.user.id}) ran a Team resync` })
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'resync')
|
||||
}
|
||||
}
|
||||
|
||||
async function archive(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const team = await teamsDb.findById(id)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
await teamsDb.archiveTeam(id, 'staff')
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.archive',
|
||||
detail: `${req.user.username} (#${req.user.id}) archived team "${team.name}" (#${id})`
|
||||
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
|
||||
})
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'archive')
|
||||
}
|
||||
}
|
||||
|
||||
async function grants(req, res) {
|
||||
try {
|
||||
return res.json({ grants: await access.grantLedger(Number(req.params.id)) })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'grants')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forum: the ledger and the upload attribution view (§5.4) ──────────────
|
||||
|
||||
/**
|
||||
* A Team's forum moderation ledger.
|
||||
*
|
||||
* Served whether or not the forum is switched on, unlike every /player forum
|
||||
* route. The switch guards the forum as a FEATURE — what members can read and
|
||||
* write — and an operator who turned it off to deal with a problem is precisely
|
||||
* the operator who needs to see what was moderated (§5.5.1: no data is deleted).
|
||||
*/
|
||||
async function forumModeration(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const team = await teamsDb.findById(id)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json({ entries: await forum.moderationLedger(id, { limit: 200 }) })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'forum moderation')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who uploaded what, when, and how much — across every Team.
|
||||
*
|
||||
* This view is the reason §5.5.4 added an attribution table at all: the
|
||||
* acknowledgement an operator gives before enabling uploads is meaningless if the
|
||||
* question it makes them responsible for cannot be answered afterwards.
|
||||
*/
|
||||
async function forumUploads(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
uploads: await forumDb.listUploads({
|
||||
limit: Number(req.query.limit) || 100,
|
||||
offset: Number(req.query.offset) || 0,
|
||||
includeDeleted: req.query.deleted === '1',
|
||||
}),
|
||||
quota: {
|
||||
dailyBytes: forumUploadsModel.DAILY_QUOTA_BYTES,
|
||||
retentionDays: forumUploadsModel.RETENTION_DAYS,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'forum uploads')
|
||||
}
|
||||
}
|
||||
|
||||
/** The forum settings' own state — the acknowledgement, which is not a public key. */
|
||||
async function forumSettingsState(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
enabled: await forumSettings.forumsEnabled(),
|
||||
imageMode: await forumSettings.imageMode(),
|
||||
// Served here rather than published as a public setting: the client that
|
||||
// needs the NUMBER is the settings screen, and the client that needs the
|
||||
// DECISION already gets it per post as `canEdit`/`editableUntil`. Publishing
|
||||
// the window would invite a client to compute the permission itself, which
|
||||
// is the one thing a time-bounded permission must not let the bounded party
|
||||
// do.
|
||||
editWindowMinutes: await forumSettings.editWindowMinutes(),
|
||||
editWindowMax: forumSettings.EDIT_WINDOW_MAX,
|
||||
acknowledgement: await forumSettings.ackState(),
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'forum settings')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
|
||||
|
||||
async function setLeaderOverride(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const team = await teamsDb.findById(id)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
|
||||
const { memberKey, effect, reason } = req.body
|
||||
await access.setLeaderOverride({
|
||||
teamId: id,
|
||||
memberKey,
|
||||
effect,
|
||||
actorUserId: req.user.id,
|
||||
actorUsername: req.user.username,
|
||||
reason: reason || null,
|
||||
})
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.leader.override',
|
||||
detail: `${req.user.username} (#${req.user.id}) set a "${effect}" leadership override on `
|
||||
+ `${memberKey} in team "${team.name}" (#${id})${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'leader-override')
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLeaderOverride(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const removed = await access.clearLeaderOverride(id, req.params.memberKey)
|
||||
if (!removed) return res.status(404).json({ message: 'No such override' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.leader.override',
|
||||
detail: `${req.user.username} (#${req.user.id}) cleared the leadership override on `
|
||||
+ `${req.params.memberKey} in team #${id}`,
|
||||
})
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'leader-override')
|
||||
}
|
||||
}
|
||||
|
||||
// ── The three gated actions, plus the ungated hide (§2.9) ─────────────────
|
||||
|
||||
async function unhide(req, res) {
|
||||
try {
|
||||
return send(res, await moderation.requestOrApply({
|
||||
req, actor: req.user, teamId: Number(req.params.id), action: 'unhide', reason: req.body.reason,
|
||||
}))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'unhide')
|
||||
}
|
||||
}
|
||||
|
||||
async function hide(req, res) {
|
||||
try {
|
||||
return send(res, await moderation.hide({
|
||||
req, actor: req.user, teamId: Number(req.params.id), reason: req.body.reason,
|
||||
}))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'hide')
|
||||
}
|
||||
}
|
||||
|
||||
async function displayName(req, res) {
|
||||
try {
|
||||
const { displayName: value, reason } = req.body
|
||||
// An empty string is how a UI says "clear it", and clearing is its own gated
|
||||
// action rather than an override set to nothing — otherwise the audit line
|
||||
// would read as though someone published a blank name.
|
||||
const action = value ? 'display_name_override' : 'clear_display_name_override'
|
||||
return send(res, await moderation.requestOrApply({
|
||||
req, actor: req.user, teamId: Number(req.params.id), action, payload: { displayName: value || null }, reason,
|
||||
}))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'display-name')
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewQueue(req, res) {
|
||||
try {
|
||||
return res.json({ teams: await moderation.reviewQueue() })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'review queue')
|
||||
}
|
||||
}
|
||||
|
||||
async function listRequests(req, res) {
|
||||
try {
|
||||
return res.json({ requests: await moderation.listRequests({ status: req.query.status || 'pending' }) })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'requests')
|
||||
}
|
||||
}
|
||||
|
||||
async function decideRequest(req, res) {
|
||||
try {
|
||||
return send(res, await moderation.decide({
|
||||
req, actor: req.user, requestId: Number(req.params.id), status: req.body.status, note: req.body.note,
|
||||
}))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'decide')
|
||||
}
|
||||
}
|
||||
|
||||
// ── The integration bridge (§7.2, phase 8) — admin only ───────────────────
|
||||
//
|
||||
// Admin-only at the ROUTER, unlike everything above it. The §2.9 gate exists
|
||||
// because a moderator's action publishes untrusted game strings to the public
|
||||
// site; this is a different risk in the other direction — it decides that
|
||||
// members-only forum text leaves the site altogether, for a destination core
|
||||
// cannot see. That is a deployment-configuration decision, and it sits with the
|
||||
// role that holds the bot token rather than with the queue.
|
||||
|
||||
async function integrationConfig(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
platform: integration.DISCORD,
|
||||
events: integration.BRIDGEABLE.map((id) => ({ id, membersOnly: integration.isMembersOnly(id) })),
|
||||
rows: await integration.list(integration.DISCORD),
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'integration config')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIntegrationConfig(req, res) {
|
||||
try {
|
||||
// `teamId` null is the deployment default and is a legitimate body, so the
|
||||
// absent-vs-null distinction matters: a PUT with no teamId edits the default.
|
||||
const teamId = req.body.teamId === undefined || req.body.teamId === null ? null : Number(req.body.teamId)
|
||||
if (teamId !== null && !(await teamsDb.findById(teamId))) {
|
||||
return res.status(404).json({ message: 'Team not found' })
|
||||
}
|
||||
|
||||
const row = await integration.save(
|
||||
{
|
||||
platform: integration.DISCORD,
|
||||
teamId,
|
||||
events: req.body.events,
|
||||
channelRef: req.body.channelRef,
|
||||
enabled: req.body.enabled,
|
||||
membersAck: req.body.membersAck,
|
||||
},
|
||||
req.user.id,
|
||||
)
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.integration.save',
|
||||
detail:
|
||||
`${req.user.username} (#${req.user.id}) saved the ${integration.DISCORD} bridge for ` +
|
||||
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}: ` +
|
||||
`${row.enabled ? 'enabled' : 'disabled'}, events [${row.events.join(', ')}]` +
|
||||
`${row.members_ack ? ', members-only destination acknowledged' : ''}`,
|
||||
})
|
||||
|
||||
return res.json(row)
|
||||
} catch (err) {
|
||||
// A validation refusal carries its own status and its own wording — the
|
||||
// acknowledgement message in particular is the whole explanation of why the
|
||||
// save was refused, and collapsing it into a 500 would leave the operator
|
||||
// with a screen that will not save and no reason given.
|
||||
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
|
||||
return fail(res, err, 'save integration config')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIntegrationConfig(req, res) {
|
||||
try {
|
||||
const teamId = req.params.teamId === 'default' ? null : Number(req.params.teamId)
|
||||
const removed = await integration.remove(integration.DISCORD, teamId)
|
||||
if (removed === 0) return res.status(404).json({ message: 'No configuration for that Team' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.integration.delete',
|
||||
detail:
|
||||
`${req.user.username} (#${req.user.id}) removed the ${integration.DISCORD} bridge for ` +
|
||||
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}`,
|
||||
})
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'delete integration config')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Voice channels (§7.3, phase 9) — admin only ────────────────────────────
|
||||
//
|
||||
// Admin-only for the same reason the bridge is: this creates and destroys
|
||||
// structure in somebody's Discord guild, which is deployment configuration and
|
||||
// not the kind of decision §2.9 files a request for.
|
||||
|
||||
/**
|
||||
* Everything the panel renders, in one call: the settings, the live rows, and
|
||||
* the bot's own answer about whether it can do the job.
|
||||
*
|
||||
* The preflight is here rather than behind a separate endpoint the panel polls,
|
||||
* because it is not a detail — an operator whose bot lacks Manage Roles has a
|
||||
* screen full of controls that cannot work, and finding that out needs to be the
|
||||
* first thing on the page rather than the result of pressing something.
|
||||
*/
|
||||
async function voiceConfig(req, res) {
|
||||
try {
|
||||
const [config, rows, flight] = await Promise.all([
|
||||
voiceSettings.all(),
|
||||
voice.list(),
|
||||
// Never fatal: a bot container that is down must not take the settings
|
||||
// screen with it, since fixing the settings may be exactly why the operator
|
||||
// came. `preflight` already turns every failure into a `ready: false`.
|
||||
voiceSync.preflight().catch((err) => ({ ready: false, connected: false, reason: err.message })),
|
||||
])
|
||||
return res.json({
|
||||
platform: voice.PLATFORM,
|
||||
settings: config,
|
||||
preflight: flight,
|
||||
rows,
|
||||
lastPass: voiceSync.lastPass(),
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'voice config')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the settings, with one precondition.
|
||||
*
|
||||
* **Switching voice ON is refused 422 while the bot cannot act.** The same shape
|
||||
* §7.2's acknowledgement takes, and for the same reason: a setting that saves and
|
||||
* then quietly does nothing is worse than one that will not save. Turning it OFF
|
||||
* is never gated — an operator disabling a feature because it is misbehaving must
|
||||
* not be blocked by the misbehaviour.
|
||||
*/
|
||||
async function saveVoiceConfig(req, res) {
|
||||
try {
|
||||
const turningOn = req.body.enabled === true && !(await voiceSettings.enabled())
|
||||
if (turningOn) {
|
||||
const flight = await voiceSync.preflight()
|
||||
if (!flight.ready) {
|
||||
return res.status(422).json({
|
||||
message: flight.reason || 'the bot cannot manage channels and roles in this guild yet',
|
||||
code: 'voice_preflight_failed',
|
||||
preflight: flight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const config = await voiceSettings.save(req.body, req.user.id)
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.voice.settings',
|
||||
detail:
|
||||
`${req.user.username} (#${req.user.id}) saved the Team voice settings: `
|
||||
+ `${config.enabled ? 'enabled' : 'disabled'}, minimum ${config.minMembers} members, `
|
||||
+ `${config.graceDays}-day grace window, ${config.staffRoles.length} staff role(s)`,
|
||||
})
|
||||
|
||||
// A save that just switched it on should not wait fifteen minutes for the
|
||||
// first channel to appear.
|
||||
if (config.enabled) voiceSync.request({ reason: 'settings saved' })
|
||||
return res.json(config)
|
||||
} catch (err) {
|
||||
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
|
||||
return fail(res, err, 'save voice config')
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a pass now, awaited, so the operator gets the outcome and not a promise. */
|
||||
async function voicePass(req, res) {
|
||||
try {
|
||||
return res.json(await voiceSync.passNow('admin'))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'voice pass')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one Team's channel and role now, ignoring the grace window.
|
||||
*
|
||||
* The window exists to stop churn on a Team crossing the threshold twice in a
|
||||
* week; an operator pressing remove is not churn. It is also the only way to
|
||||
* clean up while voice is switched off, which is the one state where no pass will
|
||||
* ever reach the row.
|
||||
*/
|
||||
async function removeVoice(req, res) {
|
||||
try {
|
||||
const teamId = Number(req.params.teamId)
|
||||
const result = await voiceSync.removeNow(teamId)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ message: result.message })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.voice.remove',
|
||||
detail: `${req.user.username} (#${req.user.id}) removed the voice channel and role for Team #${teamId}`,
|
||||
})
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'remove voice')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
voiceConfig,
|
||||
saveVoiceConfig,
|
||||
voicePass,
|
||||
removeVoice,
|
||||
integrationConfig,
|
||||
saveIntegrationConfig,
|
||||
deleteIntegrationConfig,
|
||||
forumModeration,
|
||||
forumUploads,
|
||||
forumSettingsState,
|
||||
listTeams,
|
||||
getTeam,
|
||||
resync,
|
||||
archive,
|
||||
grants,
|
||||
setLeaderOverride,
|
||||
clearLeaderOverride,
|
||||
unhide,
|
||||
hide,
|
||||
displayName,
|
||||
reviewQueue,
|
||||
listRequests,
|
||||
decideRequest,
|
||||
}
|
||||
331
server/src/router/v1/admin/teams.router.js
Normal file
331
server/src/router/v1/admin/teams.router.js
Normal file
@@ -0,0 +1,331 @@
|
||||
// Admin · Teams — sync state, the review queue, the approval queue, and the staff
|
||||
// actions on a Team (TEAMS.md §2.11).
|
||||
//
|
||||
// Mounted at /api/v1/admin/teams by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Staff-wide, like /admin/activity: a moderator
|
||||
// runs the review queue, and the three actions that PUBLISH untrusted
|
||||
// game-sourced strings are gated per request inside the controller rather than
|
||||
// per route here — a moderator may call them, and calling them files a request
|
||||
// instead of applying one.
|
||||
//
|
||||
// **Declaration order matters in this file.** `/review`, `/requests` and `/resync`
|
||||
// are literal paths that would otherwise be captured by `/:id`, so every literal
|
||||
// route is declared before the first :param route. Express is first-match-wins and
|
||||
// a `/:id` ahead of `/review` would silently turn a queue into a lookup for a Team
|
||||
// whose id is "review".
|
||||
|
||||
const express = require('express')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const ctrl = require('./teams.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const teamsRouter = express.Router()
|
||||
|
||||
// The one ADMIN-only corner of a staff-wide router (§7.2, phase 8). Configuring
|
||||
// where a Team's events leave the site for is not the §2.9 kind of decision a
|
||||
// moderator files a request for; it is deployment configuration, and it sits with
|
||||
// the role that already holds the bot token.
|
||||
// Hoisted rather than written inline, and it has to stay that way: a regex
|
||||
// LITERAL followed directly by `.test(` makes swagger-autogen's static parser run
|
||||
// away, and `npm run swagger` dies with "invalid array length — heap out of
|
||||
// memory" instead of generating a spec. Phase 8 shipped it inline and left the
|
||||
// generator unable to run at all; the same regex reached through a const (the
|
||||
// idiom `modules.router.js` already uses) parses fine.
|
||||
const TEAM_ID = /^[0-9]+$/
|
||||
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
// ── Literal paths, first ───────────────────────────────────────────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'List Teams with sync state'
|
||||
// #swagger.description = 'Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.'
|
||||
// #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */
|
||||
query('archived').optional().isIn(['0', '1']),
|
||||
validate,
|
||||
ctrl.listTeams,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/resync',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Run a reconciliation now'
|
||||
// #swagger.description = 'Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */
|
||||
ctrl.resync,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/review',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The reserved-name review queue'
|
||||
// #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */
|
||||
ctrl.reviewQueue,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/requests',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The moderation approval queue'
|
||||
// #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.'
|
||||
// #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */
|
||||
query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']),
|
||||
validate,
|
||||
ctrl.listRequests,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/requests/:id/decide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Approve or reject a moderation request (admin only)'
|
||||
// #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('status').isIn(['approved', 'rejected']),
|
||||
body('note').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.decideRequest,
|
||||
)
|
||||
|
||||
// ── :id paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Both literal, and both under '/forum' rather than '/:id/forum', so they cannot
|
||||
// be captured by the '/:id' lookup below — 'forum' is not an integer, but relying
|
||||
// on the validator to reject it would mean the route table's meaning depended on
|
||||
// a param check three lines further down.
|
||||
teamsRouter.get(
|
||||
'/forum/uploads',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Upload attribution across every Team forum'
|
||||
// #swagger.description = 'Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if "who uploaded this" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.'
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size (default 100).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
// #swagger.parameters['deleted'] = { in: 'query', required: false, schema: { type: 'string', enum: ['0','1'] }, description: 'Include soft-deleted uploads.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Uploads with their attribution', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumUploadList" } } } } */
|
||||
query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
|
||||
query('offset').optional().isInt({ min: 0 }).toInt(),
|
||||
query('deleted').optional().isIn(['0', '1']),
|
||||
validate,
|
||||
ctrl.forumUploads,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/forum/settings',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The forum switch, the image policy, and the acknowledgement’s state'
|
||||
// #swagger.description = 'The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Forum settings state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumSettingsState" } } } } */
|
||||
ctrl.forumSettingsState,
|
||||
)
|
||||
|
||||
// ── The integration bridge (§7.2) — literal, and before /:id ──────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/integrations',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The Team notification bridge’s configuration (admin only)'
|
||||
// #swagger.description = 'Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Team’s members — recorded here with who gave it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Bridge configuration', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationConfig" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.integrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.put(
|
||||
'/integrations',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Create or replace one bridge destination (admin only)'
|
||||
// #swagger.description = 'Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channel’s permissions, so the operator’s acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The saved row', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationRow" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[422] = { description: 'Not enableable — no channel, no events, or a members-only event without the acknowledgement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('teamId').optional({ nullable: true }).isInt({ min: 1 }).toInt(),
|
||||
body('events').isArray({ max: 8 }),
|
||||
body('channelRef').optional({ nullable: true }).isString().trim().isLength({ max: 64 }),
|
||||
body('enabled').optional().isBoolean().toBoolean(),
|
||||
body('membersAck').optional().isBoolean().toBoolean(),
|
||||
validate,
|
||||
ctrl.saveIntegrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.delete(
|
||||
'/integrations/:teamId',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Remove one bridge destination (admin only)'
|
||||
// #swagger.description = 'Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.'
|
||||
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Team id, or the literal string default.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)),
|
||||
validate,
|
||||
ctrl.deleteIntegrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests'
|
||||
// #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.getTeam,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id/grants',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included'
|
||||
// #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.grants,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id/forum/moderation',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'A Team’s forum moderation ledger'
|
||||
// #swagger.description = 'Append-only, and deliberately separate from the site’s mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leader’s action appears only here, a staffer’s appears here AND in activity_log. Answers whether or not the forum is switched on.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The ledger, newest first', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumModerationLedger" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.forumModeration,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/archive',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Archive a Team (staff)'
|
||||
// #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.archive,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/hide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Hide a Team from public surfaces (staff)'
|
||||
// #swagger.description = 'Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.hide,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/unhide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Un-hide a Team — admin applies, moderator requests'
|
||||
// #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.unhide,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/display-name',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Set or clear a Team’s display name — admin applies, moderator requests'
|
||||
// #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.displayName,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/leader-override',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Grant or deny leadership for one member (staff)'
|
||||
// #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
body('effect').isIn(['grant', 'deny']),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.setLeaderOverride,
|
||||
)
|
||||
|
||||
teamsRouter.delete(
|
||||
'/:id/leader-override/:memberKey',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Clear a leadership override (staff)'
|
||||
// #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module’s member key.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
param('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
validate,
|
||||
ctrl.clearLeaderOverride,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
93
server/src/router/v1/admin/teamsVoice.router.js
Normal file
93
server/src/router/v1/admin/teamsVoice.router.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// Admin · Teams · Voice channels (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// Mounted at /api/v1/admin/teams/voice by `admin/index.js`, which has already
|
||||
// applied `noindex, isLoggedIn, staffOnly` above it — and which mounts this
|
||||
// prefix BEFORE `/teams`, so these paths never reach the teams router's `/:id`.
|
||||
// Every route here adds `adminOnly` on top: this creates and destroys structure
|
||||
// in somebody's Discord guild, which is deployment configuration and not the §2.9
|
||||
// kind of decision a moderator files a request for.
|
||||
//
|
||||
// **Its own file for a mechanical reason, and the reason is worth recording.**
|
||||
// These four routes belong beside the notification bridge's three in
|
||||
// `teams.router.js`, and they started there. That file sits exactly at
|
||||
// swagger-autogen's per-file limit: at twenty `teamsRouter.*` statements
|
||||
// `npm run swagger` dies with "invalid array length — heap out of memory", and at
|
||||
// nineteen it generates. ONE more statement of any shape tips it — a route with no
|
||||
// annotations at all does, and so does a bare `use`, which is why the mount is in
|
||||
// `admin/index.js` rather than here in the file it logically belongs to. The same
|
||||
// probe route added to `discordBot.router.js` generates fine, so the limit is
|
||||
// per-file and not tree-wide.
|
||||
//
|
||||
// So: if this file grows, split it again rather than moving it back.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./teams.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const voiceRouter = express.Router()
|
||||
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
// `/sync` before `/:teamId`, the same first-match-wins rule the parent file
|
||||
// follows: a `:teamId` declared first would turn the pass into a lookup for a Team
|
||||
// whose id is "sync".
|
||||
|
||||
voiceRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Team voice channel configuration and state (admin only)'
|
||||
// #swagger.description = 'The settings, every provisioned channel with its state and last error, and the bot’s own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discord’s cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Voice configuration and state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceConfig" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.voiceConfig,
|
||||
)
|
||||
|
||||
voiceRouter.put(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Save the Team voice settings (admin only)'
|
||||
// #swagger.description = 'Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The saved settings', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceSettings" } } } } */
|
||||
/* #swagger.responses[422] = { description: 'The bot cannot manage channels or roles yet', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('enabled').optional().isBoolean().toBoolean(),
|
||||
body('minMembers').optional().isInt({ min: 1, max: 10000 }).toInt(),
|
||||
body('graceDays').optional().isInt({ min: 0, max: 90 }).toInt(),
|
||||
body('staffRoles').optional({ nullable: true }),
|
||||
validate,
|
||||
ctrl.saveVoiceConfig,
|
||||
)
|
||||
|
||||
voiceRouter.post(
|
||||
'/sync',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Run a voice reconciliation now (admin only)'
|
||||
// #swagger.description = 'Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The pass result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoicePassResult" } } } } */
|
||||
adminOnly,
|
||||
ctrl.voicePass,
|
||||
)
|
||||
|
||||
voiceRouter.delete(
|
||||
'/:teamId',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Remove one Team’s voice channel and role (admin only)'
|
||||
// #swagger.description = 'Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.'
|
||||
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'integer' } }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'That Team has no voice channel', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('teamId').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.removeVoice,
|
||||
)
|
||||
|
||||
module.exports = voiceRouter
|
||||
@@ -6,6 +6,7 @@
|
||||
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
|
||||
const log = require('../../../utils/logger')('notifications')
|
||||
@@ -78,6 +79,39 @@ async function putSubscriptions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/teams — this user's per-Team preferences, one row
|
||||
// per Team they could be notified about whether or not they have ever set one.
|
||||
//
|
||||
// Not gated on `teams_forums_enabled`: two of the four streams (member joined,
|
||||
// leadership changed) have nothing to do with the forum, so a deployment with
|
||||
// forums switched off still has preferences worth showing.
|
||||
async function getTeamPrefs(req, res) {
|
||||
try {
|
||||
return res.json({ teams: await teamPrefs.listPrefs(req.user.id) })
|
||||
} catch (err) {
|
||||
log.error('getTeamPrefs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /auth/me/notifications/teams — replace the caller's whole preference set.
|
||||
//
|
||||
// PUT-the-whole-set, matching the subscriptions endpoint beside it, and the
|
||||
// `teams` array is REQUIRED even when empty — the Android gotcha in
|
||||
// docs/android/PLAN.md §11: a DTO field with a default is dropped by kotlinx when
|
||||
// it equals that default, so clearing the last entry would arrive as a body with
|
||||
// no array at all and 400. Entries naming a Team the caller is not in are dropped
|
||||
// by the model rather than refused here (an ordinary race, not a client bug).
|
||||
async function putTeamPrefs(req, res) {
|
||||
try {
|
||||
const { prefs } = await teamPrefs.replacePrefs(req.user.id, req.body.teams)
|
||||
return res.json({ teams: prefs })
|
||||
} catch (err) {
|
||||
log.error('putTeamPrefs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerDevice,
|
||||
listDevices,
|
||||
@@ -85,4 +119,6 @@ module.exports = {
|
||||
getStreams,
|
||||
getSubscriptions,
|
||||
putSubscriptions,
|
||||
getTeamPrefs,
|
||||
putTeamPrefs,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const notif = require('./notifications.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
|
||||
|
||||
const notifRouter = express.Router()
|
||||
|
||||
@@ -97,4 +98,39 @@ notifRouter.put(
|
||||
notif.putSubscriptions,
|
||||
)
|
||||
|
||||
// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
|
||||
//
|
||||
// The granularity per-stream opt-in cannot express: "I am in five Teams and want
|
||||
// notifications from one". Opt-OUT for push (no row means notified) and opt-IN
|
||||
// for email, so a user who never opens this screen is in the state the schema
|
||||
// documents rather than in one this router has to describe.
|
||||
notifRouter.get(
|
||||
'/notifications/teams',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Get the current user’s per-Team notification preferences'
|
||||
// #swagger.description = 'One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` "off".'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Per-Team preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
notif.getTeamPrefs,
|
||||
)
|
||||
|
||||
notifRouter.put(
|
||||
'/notifications/teams',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Replace the current user’s per-Team notification preferences'
|
||||
// #swagger.description = 'Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('teams').isArray(),
|
||||
body('teams.*.teamId').isInt({ min: 1 }),
|
||||
body('teams.*.muted').optional().isBoolean(),
|
||||
body('teams.*.emailMode').optional().isIn(EMAIL_MODES),
|
||||
validate,
|
||||
notif.putTeamPrefs,
|
||||
)
|
||||
|
||||
module.exports = notifRouter
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const botConfig = require('../../../model/botConfig/botConfig.model')
|
||||
const slashCommands = require('../../../utils/slashCommands')
|
||||
const log = require('../../../utils/logger')('internal')
|
||||
|
||||
// GET /internal/bot-config — called by the bot process on its own boot so a
|
||||
@@ -16,4 +17,40 @@ async function getBotConfig(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getBotConfig }
|
||||
// GET /internal/commands — the registered slash-command definitions, pulled by
|
||||
// the bot on `ready` and again whenever it is nudged (TEAMS.md §7.1).
|
||||
//
|
||||
// `version` is `modules.version()`, the counter every module state change bumps.
|
||||
// The bot holds the value it registered with and re-PUTs only when it differs,
|
||||
// which is what makes DEREGISTRATION free: the bot's single whole-set
|
||||
// `REST.put(applicationGuildCommands)` means a module that is gone is simply
|
||||
// absent from the next pull, with nobody having to remember to unregister it.
|
||||
function listCommands(req, res) {
|
||||
return res.json(slashCommands.definitions())
|
||||
}
|
||||
|
||||
// POST /internal/commands/dispatch — run one command and answer with the
|
||||
// envelope. Never 500s on a handler's behalf: `dispatch` catches per handler and
|
||||
// reports `{ ok: false, reason }`, so the bot always has something to render and
|
||||
// a module's failure is its own.
|
||||
async function dispatchCommand(req, res) {
|
||||
const { command, options, platform, platformUserId, guildId } = req.body || {}
|
||||
if (!command) return res.status(400).json({ ok: false, reason: 'unknown' })
|
||||
try {
|
||||
const result = await slashCommands.dispatch({
|
||||
command,
|
||||
options: options && typeof options === 'object' ? options : {},
|
||||
platform: platform || 'discord',
|
||||
platformUserId,
|
||||
guildId,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
// dispatch() is documented never to throw; if it ever does, that is core's
|
||||
// bug and not the module's, and it is logged as one.
|
||||
log.error('internal.dispatchCommand', err)
|
||||
return res.status(500).json({ ok: false, reason: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getBotConfig, listCommands, dispatchCommand }
|
||||
|
||||
@@ -16,4 +16,20 @@ router.get(
|
||||
ctrl.getBotConfig,
|
||||
)
|
||||
|
||||
// The slash-command seam (TEAMS.md §7.1). Both stay off the public API and out
|
||||
// of the OpenAPI document for the same reason /bot-config does: the caller is
|
||||
// the bot process on the private compose network, and `/internal/*` is not a
|
||||
// published contract.
|
||||
router.get(
|
||||
'/commands',
|
||||
// #swagger.ignore = true
|
||||
ctrl.listCommands,
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/commands/dispatch',
|
||||
// #swagger.ignore = true
|
||||
ctrl.dispatchCommand,
|
||||
)
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -26,6 +26,8 @@ const noindex = require('../../../middleware/noindex')
|
||||
|
||||
const accountRouter = require('./account.router')
|
||||
const appealsRouter = require('./appeals.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamForumRouter = require('./teamForum.router')
|
||||
|
||||
const playerRouter = express.Router()
|
||||
|
||||
@@ -39,5 +41,10 @@ playerRouter.use(noindex, requireAuth)
|
||||
|
||||
playerRouter.use('/account', accountRouter)
|
||||
playerRouter.use('/appeals', appealsRouter)
|
||||
playerRouter.use('/teams', teamsRouter)
|
||||
// Same prefix, second router. The forum and the leader-exercised grant flow are a
|
||||
// different capability from "the caller's own Teams", and splitting them keeps
|
||||
// each file about one thing; no path in the two collides.
|
||||
playerRouter.use('/teams', teamForumRouter)
|
||||
|
||||
module.exports = playerRouter
|
||||
|
||||
485
server/src/router/v1/player/teamForum.controller.js
Normal file
485
server/src/router/v1/player/teamForum.controller.js
Normal file
@@ -0,0 +1,485 @@
|
||||
// Player · Team forums — the participant surface (TEAMS.md §5.4).
|
||||
//
|
||||
// Under `/player` rather than `/admin` for the reason §2.11 gives: a forum
|
||||
// participant may be a plain player, a LEADER is a player, and the `/admin` tier
|
||||
// gate is `requireRole('admin','editor','moderator')` — putting a leader endpoint
|
||||
// behind it would mean widening that gate. The leader check is a per-handler
|
||||
// question on top of the tier's `requireAuth`.
|
||||
//
|
||||
// **Two guards run before anything else in this file, in this order:**
|
||||
//
|
||||
// 1. `teams_forums_enabled` — off means every route here answers 404, not 403.
|
||||
// A 403 says "this exists and you may not have it", which advertises a
|
||||
// feature the operator deliberately turned off; 404 says "not a thing on
|
||||
// this site", which is the true statement (§5.5.1).
|
||||
// 2. the §2.5 access resolver — and never a membership check. Both a member and
|
||||
// a granted non-member reach the forum, and asking `team_members` directly
|
||||
// here is precisely how paths 1 and 3 drift back together.
|
||||
//
|
||||
// Both live in `resolveForum` below so a handler cannot forget either.
|
||||
|
||||
const teamsDb = require('../../../model/teams/teams.db')
|
||||
const access = require('../../../model/teams/teamAccess.model')
|
||||
const grants = require('../../../model/teams/teamGrants.model')
|
||||
const forum = require('../../../model/teams/teamForum.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const uploads = require('../../../model/teams/teamForumUploads.model')
|
||||
const reports = require('../../../model/reports/contentReports.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const teamNotify = require('../../../utils/teamNotify')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
const STAFF_ROLES = ['admin', 'moderator']
|
||||
const isStaff = (user) => STAFF_ROLES.includes(user?.role)
|
||||
|
||||
const fail = (res, err, what) => {
|
||||
log.error(`player team forum: ${what} failed`, { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
const send = (res, result, body = { ok: true }) =>
|
||||
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
|
||||
|
||||
/**
|
||||
* The two guards, plus the Team, plus what this caller may do in it.
|
||||
*
|
||||
* Returns null when the caller should see a 404 — which covers three different
|
||||
* situations on purpose: the forum is switched off, the Team does not exist, and
|
||||
* the caller has no access to it. A private room's contents and its existence are
|
||||
* the same secret.
|
||||
*/
|
||||
async function resolveForum(req) {
|
||||
if (!(await forumSettings.forumsEnabled())) return null
|
||||
const team = await teamsDb.findBySlug(req.params.slug)
|
||||
if (!team) return null
|
||||
|
||||
const resolved = await access.forumAccess(team.id, req.user.id)
|
||||
const staff = isStaff(req.user)
|
||||
if (!resolved.allowed && !staff) return null
|
||||
|
||||
return {
|
||||
team,
|
||||
access: resolved,
|
||||
staff,
|
||||
// Staff moderate anywhere; a leader moderates their own Team. `actorRole`
|
||||
// records WHICH of the two was exercised, and leadership wins when both are
|
||||
// true: a leader who is also a moderator acting on their own Team is doing
|
||||
// ordinary housekeeping, and logging it as a staff intervention would put a
|
||||
// guild's day-to-day tidying into the site's staff-accountability trail.
|
||||
canModerate: resolved.isLeader || staff,
|
||||
actorRole: resolved.isLeader ? 'leader' : 'staff',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is reading, for the read path's per-post `canEdit`.
|
||||
*
|
||||
* A separate read of the edit window rather than one folded into `resolveForum`,
|
||||
* because only the two routes that render posts need it and `resolveForum` runs
|
||||
* on every route in this file including the ones that never look at a body.
|
||||
*/
|
||||
async function viewerFor(ctx, user) {
|
||||
return {
|
||||
userId: user.id,
|
||||
isStaff: ctx.staff,
|
||||
windowMinutes: await forumSettings.editWindowMinutes(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan a new thread or reply out to the Team (TEAMS.md Part 6, phase 6).
|
||||
*
|
||||
* **Here rather than in the forum model**, because the model takes an
|
||||
* already-resolved access decision and reads no membership table by design, and
|
||||
* the fan-out reads both to compute its recipients. A notification call inside the
|
||||
* model would make it transitively depend on what its own header says it must not.
|
||||
*
|
||||
* **Awaited, and it still cannot fail the request.** `teamNotify.forumPost` catches
|
||||
* everything and returns; awaiting it costs the response the time of one recipient
|
||||
* query plus, in `immediate` mode, the SMTP calls — which is why the alternative
|
||||
* (fire-and-forget) is tempting and wrong here: an un-awaited rejection in an
|
||||
* Express handler is an unhandled rejection, and the tests would have no moment at
|
||||
* which to assert the fan-out happened.
|
||||
*/
|
||||
async function announce(ctx, actor, notify) {
|
||||
if (!notify) return
|
||||
await teamNotify.forumPost({
|
||||
team: ctx.team,
|
||||
threadId: notify.threadId,
|
||||
threadTitle: notify.title,
|
||||
type: notify.type,
|
||||
authorUserId: actor.id,
|
||||
authorName: actor.username,
|
||||
bodyHtml: notify.bodyHtml,
|
||||
})
|
||||
}
|
||||
|
||||
// ── threads ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listThreads(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json({
|
||||
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
|
||||
// Two capabilities, not one. Phase 4 had a single `canPost` because there
|
||||
// was a single kind of thread to post; phase 5 opened discussion to every
|
||||
// participant while announcements stayed with the leaders, so a client that
|
||||
// read one boolean would have to guess which right it described.
|
||||
// `canPost` is kept and now means "may open a discussion", which is what a
|
||||
// 5a client's composer was for — an old client offering the composer to a
|
||||
// member is a client offering the thing the server now allows.
|
||||
canPost: true,
|
||||
canAnnounce: ctx.canModerate,
|
||||
canModerate: ctx.canModerate,
|
||||
imageMode: await forumSettings.imageMode(),
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'list threads')
|
||||
}
|
||||
}
|
||||
|
||||
async function getThread(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), {
|
||||
canModerate: ctx.canModerate,
|
||||
viewer: await viewerFor(ctx, req.user),
|
||||
})
|
||||
if (!thread) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json({ ...thread, canModerate: ctx.canModerate })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'get thread')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a thread.
|
||||
*
|
||||
* **The check splits by TYPE, which is what phase 4 said would happen here.** An
|
||||
* announcement is leader-authored; a discussion is open to every participant — and
|
||||
* "participant" means anyone `resolveForum` let through, which includes a granted
|
||||
* non-member with no game identity at all. That is path 3 doing its job: a forum
|
||||
* guest reads and writes exactly as a member does, because the alternative is a
|
||||
* second class of reader whose rights have to be tracked somewhere else.
|
||||
*
|
||||
* The default type is still `announcement`, unchanged from 5a: a client that
|
||||
* posts without saying what it is posting is a 5a client, and a 5a client only
|
||||
* ever posted announcements. Defaulting the other way would silently turn its
|
||||
* announcements into discussions.
|
||||
*/
|
||||
async function createThread(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
|
||||
const type = req.body.type || 'announcement'
|
||||
if (type === 'announcement' && !ctx.canModerate) {
|
||||
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
|
||||
}
|
||||
|
||||
const { notify, ...result } = await forum.createThread({
|
||||
team: ctx.team,
|
||||
actor: req.user,
|
||||
type,
|
||||
title: req.body.title,
|
||||
body: req.body.body,
|
||||
})
|
||||
if (result.ok) await announce(ctx, req.user, notify)
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create thread')
|
||||
}
|
||||
}
|
||||
|
||||
/** Reply to a discussion thread. Every participant may; the model decides the rest. */
|
||||
async function createPost(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
|
||||
const { notify, ...result } = await forum.createPost({
|
||||
team: ctx.team,
|
||||
threadId: Number(req.params.id),
|
||||
actor: req.user,
|
||||
body: req.body.body,
|
||||
})
|
||||
if (result.ok) await announce(ctx, req.user, notify)
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create post')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a post.
|
||||
*
|
||||
* A staff edit of somebody else's words is an intervention and writes
|
||||
* `activity_log` (§5.3) — the one asymmetry that keeps the site's
|
||||
* staff-accountability trail complete without dragging a member fixing their own
|
||||
* typo into it. The model reports which case this was; the controller never
|
||||
* re-derives it, because the two would disagree the day one of them changed.
|
||||
*/
|
||||
async function editPost(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
|
||||
const result = await forum.editPost({
|
||||
team: ctx.team,
|
||||
postId: Number(req.params.id),
|
||||
actor: req.user,
|
||||
isStaff: ctx.staff,
|
||||
windowMinutes: await forumSettings.editWindowMinutes(),
|
||||
body: req.body.body,
|
||||
})
|
||||
if (result.ok && result.staffEdit) {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.edit',
|
||||
detail: `${req.user.username} (#${req.user.id}) edited post #${req.params.id} `
|
||||
+ `on team "${ctx.team.name}" (#${ctx.team.id})`,
|
||||
})
|
||||
}
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'edit post')
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide, unhide, delete or restore one post. Pin and lock belong to threads. */
|
||||
async function moderatePost(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' })
|
||||
|
||||
const result = await forum.moderatePost({
|
||||
team: ctx.team,
|
||||
postId: Number(req.params.id),
|
||||
action: req.body.action,
|
||||
actor: req.user,
|
||||
actorRole: ctx.actorRole,
|
||||
reason: req.body.reason,
|
||||
})
|
||||
if (result.ok && ctx.actorRole === 'staff') {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.moderate',
|
||||
detail: `${req.user.username} (#${req.user.id}) ${req.body.action} post #${req.params.id} `
|
||||
+ `on team "${ctx.team.name}" (#${ctx.team.id})`
|
||||
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
|
||||
})
|
||||
}
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'moderate post')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin / lock / hide / delete a thread, and its opposites.
|
||||
*
|
||||
* A staff-exercised action ALSO writes `activity_log`; a leader-exercised one
|
||||
* writes only the forum ledger (§5.3). That asymmetry is the whole reason the two
|
||||
* ledgers are cross-referenced rather than merged: routing a guild leader locking
|
||||
* a thread into the site's sanction pipeline would make ordinary housekeeping an
|
||||
* appealable staff action.
|
||||
*/
|
||||
async function moderateThread(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' })
|
||||
|
||||
const result = await forum.moderateThread({
|
||||
team: ctx.team,
|
||||
threadId: Number(req.params.id),
|
||||
action: req.body.action,
|
||||
actor: req.user,
|
||||
actorRole: ctx.actorRole,
|
||||
reason: req.body.reason,
|
||||
})
|
||||
if (result.ok && ctx.actorRole === 'staff') {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.moderate',
|
||||
detail: `${req.user.username} (#${req.user.id}) ${req.body.action} thread #${req.params.id} `
|
||||
+ `on team "${ctx.team.name}" (#${ctx.team.id})`
|
||||
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
|
||||
})
|
||||
}
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'moderate thread')
|
||||
}
|
||||
}
|
||||
|
||||
// ── grants (§2.5 path 3, leader-exercised) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* The grant surface is reachable whether or not the FORUM is on.
|
||||
*
|
||||
* Not an oversight: §5.5.1 says a toggle-off revokes no grant and that the rows
|
||||
* stay authoritative, so a leader must still be able to see and manage them —
|
||||
* they simply have nothing to grant access to for the moment. What the switch
|
||||
* guards is the forum's CONTENT, not its access list.
|
||||
*/
|
||||
async function listGrants(req, res) {
|
||||
try {
|
||||
const team = await teamsDb.findBySlug(req.params.slug)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
const authority = await grants.authorityFor(team.id, req.user)
|
||||
if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' })
|
||||
return res.json({
|
||||
guests: await grants.forumGuests(team.id),
|
||||
cap: await grants.grantCap(),
|
||||
as: authority.as,
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'list grants')
|
||||
}
|
||||
}
|
||||
|
||||
async function createGrant(req, res) {
|
||||
try {
|
||||
const team = await teamsDb.findBySlug(req.params.slug)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
const result = await grants.grant({
|
||||
team,
|
||||
actor: req.user,
|
||||
userId: req.body.userId,
|
||||
username: req.body.username,
|
||||
reason: req.body.reason,
|
||||
})
|
||||
if (result.ok && result.as === 'staff') {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.grant',
|
||||
detail: `${req.user.username} (#${req.user.id}) granted forum access to ${result.grantee} `
|
||||
+ `on team "${team.name}" (#${team.id})`,
|
||||
})
|
||||
}
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create grant')
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeGrant(req, res) {
|
||||
try {
|
||||
const team = await teamsDb.findBySlug(req.params.slug)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
const result = await grants.revoke({
|
||||
team,
|
||||
actor: req.user,
|
||||
userId: Number(req.params.userId),
|
||||
reason: req.body.reason,
|
||||
})
|
||||
if (result.ok && result.as === 'staff') {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.revoke',
|
||||
detail: `${req.user.username} (#${req.user.id}) revoked forum access from ${result.grantee} `
|
||||
+ `on team "${team.name}" (#${team.id})`,
|
||||
})
|
||||
}
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'revoke grant')
|
||||
}
|
||||
}
|
||||
|
||||
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* File a report about a thread, a post or an upload.
|
||||
*
|
||||
* **This is the one write in this file that does nothing to the content.** A
|
||||
* report opens a queue item and changes no status, no flag and no counter — which
|
||||
* is what keeps it out of §5.3's moderation ledger, and what stops "report" from
|
||||
* becoming a way for any participant to hide anything.
|
||||
*
|
||||
* It reaches SITE STAFF and nobody else. The hole §5.6 closes is that leaders
|
||||
* moderate their own Team and a Team's leaders are exactly the people who will
|
||||
* not report their own Team, so a leader-visible queue would hand a complaint
|
||||
* about a leader straight back to them. There is deliberately no leader-facing
|
||||
* view anywhere in this phase (org lead, 2026-08-18).
|
||||
*
|
||||
* The route sits behind the same `resolveForum` guard as everything else, so a
|
||||
* reporter is by construction someone who can already see what they are
|
||||
* reporting — and the model additionally checks the target really belongs to the
|
||||
* Team the request came through, or the queue's per-Team filter would be lying.
|
||||
*/
|
||||
async function createReport(req, res) {
|
||||
try {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
|
||||
return send(res, await reports.file({
|
||||
team: ctx.team,
|
||||
actor: req.user,
|
||||
targetType: req.body.targetType,
|
||||
targetId: Number(req.body.targetId),
|
||||
reason: req.body.reason,
|
||||
detail: req.body.detail,
|
||||
}))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create report')
|
||||
}
|
||||
}
|
||||
|
||||
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The same 404 guard, applied at a second level: these routes answer 404 in any
|
||||
* image mode but `uploads`, for the same reason the forum's do when the switch is
|
||||
* off. An upload control the client offers and the server refuses is worse than
|
||||
* no control, which is why the mode is published (§5.5.6) — but the SERVER is
|
||||
* still what enforces it.
|
||||
*/
|
||||
async function createUpload(req, res) {
|
||||
try {
|
||||
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
||||
|
||||
return send(res, await uploads.accept({ team: ctx.team, actor: req.user, file: req.file }))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'upload')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUpload(req, res) {
|
||||
try {
|
||||
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return send(res, await uploads.remove({
|
||||
id: Number(req.params.id),
|
||||
actor: req.user,
|
||||
isStaff: isStaff(req.user),
|
||||
}))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'delete upload')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listThreads,
|
||||
getThread,
|
||||
createThread,
|
||||
createPost,
|
||||
editPost,
|
||||
moderateThread,
|
||||
moderatePost,
|
||||
listGrants,
|
||||
createGrant,
|
||||
revokeGrant,
|
||||
createUpload,
|
||||
deleteUpload,
|
||||
createReport,
|
||||
}
|
||||
297
server/src/router/v1/player/teamForum.router.js
Normal file
297
server/src/router/v1/player/teamForum.router.js
Normal file
@@ -0,0 +1,297 @@
|
||||
// Player · Team forums (TEAMS.md §5.4) and the leader-exercised grant flow (§2.11).
|
||||
//
|
||||
// Mounted at /api/v1/player/teams by player/index.js — the SAME prefix as
|
||||
// teams.router.js, which is why this file exists separately rather than being
|
||||
// merged into it: that router is the caller's own Team reads, this one is the
|
||||
// forum and the grants. Express walks both in mount order and no path collides
|
||||
// ('/:slug/access' vs '/:slug/forum/*' and '/:slug/grants').
|
||||
//
|
||||
// Every forum route here 404s while `teams_forums_enabled` is off, and the upload
|
||||
// routes 404 in any image mode but `uploads`. Both guards are in the controller
|
||||
// rather than in middleware here, because both need the resolved Team and the
|
||||
// caller's access to decide, and a guard that answers before those are known
|
||||
// would have to answer 403 — which is the thing §5.5.1 says not to say.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./teamForum.controller')
|
||||
const contentReports = require('../../../model/reports/contentReports.model')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { makeLimiter } = require('../../../middleware/rateLimit')
|
||||
const { upload } = require('../admin/imageUpload')
|
||||
|
||||
const forumRouter = express.Router()
|
||||
|
||||
// Writes are rate-limited, reads are not. The caps are per IP and generous enough
|
||||
// that a Team having a busy afternoon never meets them; what they stop is a script.
|
||||
const postLimiter = makeLimiter({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 20,
|
||||
label: 'team-forum-post',
|
||||
message: 'Too many forum posts. Please slow down.',
|
||||
})
|
||||
|
||||
// Tighter than posting, and for a different reason: §2.5 caps how many active
|
||||
// grants a Team may hold, and this caps how fast a leader may approach that cap.
|
||||
const grantLimiter = makeLimiter({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 15,
|
||||
label: 'team-forum-grant',
|
||||
message: 'Too many grant changes. Please slow down.',
|
||||
})
|
||||
|
||||
// Tightest of the three, and §5.6's third rule is why: a report costs the
|
||||
// reporter nothing and costs a staffer attention, so the queue is the one surface
|
||||
// here that can be used as a harassment tool. The unique key already stops
|
||||
// duplicate open reports on one target; this stops a spread of them.
|
||||
const reportLimiter = makeLimiter({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 10,
|
||||
label: 'team-forum-report',
|
||||
message: 'Too many reports. Please give staff a chance to look at the ones you have raised.',
|
||||
})
|
||||
|
||||
// Bytes, not requests: the per-account daily quota lives in the uploads model,
|
||||
// and this is the per-IP flood guard in front of it.
|
||||
const uploadLimiter = makeLimiter({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 30,
|
||||
label: 'team-forum-upload',
|
||||
message: 'Too many uploads. Please slow down.',
|
||||
})
|
||||
|
||||
forumRouter.get(
|
||||
'/:slug/forum/threads',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'List a Team forum’s threads'
|
||||
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The thread list, with what this caller may do', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThreadList" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Forum off, no such Team, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listThreads,
|
||||
)
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/threads',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Open a thread — an announcement or a discussion'
|
||||
// #swagger.description = 'Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement','discussion'], default: 'announcement' }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Only a leader may post an announcement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
postLimiter,
|
||||
param('slug').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
body('type').optional().isIn(['announcement', 'discussion']),
|
||||
body('title').isString().trim().isLength({ min: 1, max: 200 }),
|
||||
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||
validate,
|
||||
ctrl.createThread,
|
||||
)
|
||||
|
||||
forumRouter.get(
|
||||
'/:slug/forum/threads/:id',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Read one thread and its posts'
|
||||
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The thread', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThread" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Forum off, no such thread, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.getThread,
|
||||
)
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/threads/:id/moderate',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Pin, lock, hide or delete a thread'
|
||||
// #swagger.description = 'Leader or staff. Every action writes the Team’s own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the site’s staff-accountability trail sees it while a leader’s ordinary housekeeping stays out of it. Deliberately not routed through the site’s mod_actions/appeals pair, which is Discord-sanction-shaped.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['pin','unpin','lock','unlock','hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, threadId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('action').isIn(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.moderateThread,
|
||||
)
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/threads/:id/posts',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Reply to a discussion thread'
|
||||
// #swagger.description = 'Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the thread’s state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' }, postId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Announcements do not take replies', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The thread is locked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
postLimiter,
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||
validate,
|
||||
ctrl.createPost,
|
||||
)
|
||||
|
||||
forumRouter.patch(
|
||||
'/:slug/forum/posts/:id',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Edit a post'
|
||||
// #swagger.description = 'The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone else’s post additionally writes `activity_log`; a member fixing their own typo does not.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Edited', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not your post, or the edit window has closed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Forum off, no such post, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
postLimiter,
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||
validate,
|
||||
ctrl.editPost,
|
||||
)
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/posts/:id/moderate',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Hide, unhide, delete or restore a post'
|
||||
// #swagger.description = 'Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so "everything moderated in this Team" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a thread’s place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'An action that applies to a thread, not a post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
// **Deliberately the FULL action list, not the four a post accepts.** The model
|
||||
// answers `pin` with "that applies to a thread, not to a post" and an invented
|
||||
// action with "unknown", and a validator that allowed only the four would turn
|
||||
// the first of those into a generic "Validation failed" — leaving the precise
|
||||
// message reachable only from a unit test. Found on the live rig, where `pin`
|
||||
// came back as a validation error rather than as the sentence written for it.
|
||||
// Both are 400 and neither is a security boundary; the difference is entirely
|
||||
// whether the caller is told which mistake they made.
|
||||
body('action').isIn(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.moderatePost,
|
||||
)
|
||||
|
||||
// ── grants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
forumRouter.get(
|
||||
'/:slug/grants',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'The Team’s forum guests, and the per-Team cap'
|
||||
// #swagger.description = 'Leader or staff. Lists ACTIVE grants for accounts that are not members — someone who is both is a member, appears on the roster, and is absent here. Answers regardless of whether the forum is switched on: a toggle-off revokes no grant, so the access list stays manageable while there is temporarily nothing to grant access to.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Forum guests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumGuestList" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listGrants,
|
||||
)
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/grants',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Grant forum access to an account'
|
||||
// #swagger.description = 'A grant may name ANY Runic Gateway account, including one with no linked game identity — that is the point of it, since letting an unlinked guildmate into the forum must not be a staff ticket. It never writes team_members: the grantee stays off the roster, out of every membership count, and ineligible for external-platform access. A leader is capped at `teams_max_grants_per_team` active grants (default 50) and rate-limited; staff are exempt and are warned on the way past.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { userId: { type: 'integer' }, username: { type: 'string' }, reason: { type: 'string', maxLength: 255 } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Granted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' }, warning: { type: 'string' } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Already granted, or the Team is at its cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
grantLimiter,
|
||||
body('userId').optional().isInt({ min: 1 }).toInt(),
|
||||
body('username').optional().isString().trim().isLength({ min: 1, max: 32 }),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.createGrant,
|
||||
)
|
||||
|
||||
forumRouter.delete(
|
||||
'/:slug/grants/:userId',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Revoke forum access'
|
||||
// #swagger.description = 'The grant row is updated rather than deleted — the table is the audit ledger as well as the current state. A leader may not revoke a STAFF-issued grant, which is what stops a leader undoing a moderation decision; the issuer’s role is checked at revoke time, so an account that has since lost its staff role stops protecting the grants it made.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['userId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grantee’s account id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not a leader, or the grant was staff-issued', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
grantLimiter,
|
||||
param('userId').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.revokeGrant,
|
||||
)
|
||||
|
||||
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/report',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Report a thread, post or upload to site staff'
|
||||
// #swagger.description = 'The first user-facing report flow core has ever had. **A report is not a moderation action** — it changes nothing about the content and opens a queue item, which is what keeps it out of the Team’s moderation ledger and stops "report" becoming a way for any participant to hide anything. It reaches SITE STAFF and nobody else: leaders moderate their own Team, and a Team’s leaders are exactly the people who will not report their own Team, so there is no leader-facing view of this queue anywhere. One open report per (target, reporter) — a second answers 409 rather than pretending to succeed — plus an hourly per-IP cap.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['targetType','targetId','reason'], properties: { targetType: { type: 'string', enum: ['team_forum_thread','team_forum_post','team_forum_upload'] }, targetId: { type: 'integer' }, reason: { type: 'string', enum: ['spam','abuse','sexual','illegal','impersonation','other'] }, detail: { type: 'string', maxLength: 500 } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Forum off, no access, or the target is not in this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
reportLimiter,
|
||||
body('targetType').isIn(contentReports.TARGET_TYPES),
|
||||
body('targetId').isInt({ min: 1 }).toInt(),
|
||||
body('reason').isIn(contentReports.REASONS),
|
||||
body('detail').optional().isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
ctrl.createReport,
|
||||
)
|
||||
|
||||
// ── uploads ────────────────────────────────────────────────────────────────
|
||||
|
||||
forumRouter.post(
|
||||
'/:slug/forum/uploads',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Upload an image to a Team forum'
|
||||
// #swagger.description = 'Multipart. Answers 404 in any image mode but `uploads`. Beyond the admin upload path’s 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a client’s Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: 'object', properties: { image: { type: 'string', format: 'binary' } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Stored', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, id: { type: 'integer' }, url: { type: 'string' }, bytes: { type: 'integer' } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Not the image type it claims to be', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Daily upload quota reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
uploadLimiter,
|
||||
upload.single('image'),
|
||||
ctrl.createUpload,
|
||||
)
|
||||
|
||||
forumRouter.delete(
|
||||
'/:slug/forum/uploads/:id',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'Remove an uploaded image'
|
||||
// #swagger.description = 'The uploader or staff. Soft: the row is marked and the bytes go with the nightly sweep after a retention window, so a mis-click is recoverable. Note that disabling uploads later stops new files being accepted and does not remove files already uploaded — that is what this route is for.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The upload id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not your upload', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.deleteUpload,
|
||||
)
|
||||
|
||||
module.exports = forumRouter
|
||||
28
server/src/router/v1/player/teams.controller.js
Normal file
28
server/src/router/v1/player/teams.controller.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Player · Teams — self-scoped reads. Neither handler takes an identity from the
|
||||
// caller; both use req.user.id, which the tier's requireAuth has already proved.
|
||||
|
||||
const teams = require('../../../model/teams/teams.model')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
async function listMine(req, res) {
|
||||
try {
|
||||
return res.json(await teams.listForUser(req.user.id))
|
||||
} catch (err) {
|
||||
log.error('player teams: list failed', { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getMyAccess(req, res) {
|
||||
try {
|
||||
const resolved = await teams.accessForUser(req.params.slug, req.user.id)
|
||||
if (!resolved) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json(resolved)
|
||||
} catch (err) {
|
||||
log.error('player teams: access failed', { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listMine, getMyAccess }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user