feat(console): let a guild say where its protocols should go - #150
Merged
Conversation
A guild has been able to publish to several destinations in several formats since the export-target API landed, and none of it was reachable from the interface: `document_target` on Bot Settings still looked like the only answer, when it is now the fallback for a guild that configures nothing else. Adds a Destinations page to the Admin View over the CRUD routes, a write-only credential control, and — on the recording page — the list of what one meeting was actually published as. The decisions live in two pure modules and are tested without mounting anything: which formats are offered and why `pdf` and `confluence` are absent rather than disabled, what each format will accept as an address, which destination Discord announces, what may be done to a credential, and how a partly-published meeting reads.
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Puts the export destinations on a page of their own, gives the credential a control that cannot silently clear it, and turns the recording page's single "Open the protocol" button into the list of where a meeting actually went.
The problem
A guild can publish to several destinations in several formats.
GET/POST/PUT/DELETEon/api/guilds/{id}/export-targetshave been live since #144,session_documentrecords one row per destination that succeeded, and none of it is reachable from the interface. What an administrator sees isdocument_targeton Bot Settings — a single Outline collection id in a text field — which now is not the answer but the fallback for a guild that has configured nothing else.destinations_forreplaces it with the configured destinations rather than joining them, so a guild that switches one destination on here silently stops using that setting, and nothing anywhere said so.The recording page has the mirror of the same problem. It renders
session.document_urlas one link, and that field is stamped from the primary destination alone. A meeting that reached two destinations out of three shows one button and reads as finished.The page
/admin/destinations, in the Admin View, second — beside the configuration it used to be part of, so the two do not read as rival settings. Built out of #133's controls:UiSelectfor the guild switcher and both pickers,UiDisclosureListfor the rows,UiPaginationunder them.Every decision is in
app/utils/exportTargets.tsand tested without rendering anything; the page is layout, request plumbing, and which panel is open. The guild switcher is not reimplemented either — it is the samechooseGuild/readSelectedGuildBot Settings and the Queue use, remembered choice included.The order is by id, and that is not cosmetic.
destinations_forsorts by id precisely so the primary does not move when somebody renames one, and the primary is the link the Discord announcement carries. So the list is ordered the way it publishes, the first enabled row wears anAnnounced in Discordbadge, and a sentence above the list says why. Sorting alphabetically would have shown a first row that is not the first destination — the one fact on this page with a consequence outside it.pdfandconfluenceare absent, not disabledThey are specified and deliberately not built (
export_formats, spec §3.4), and configuring one answers 400 with{"supported": [...]}. The console has two precedents for an option it cannot honour and they point opposite ways, so this had to be argued rather than picked.The account menu renders two-factor authentication as an inert coming soon row. That is right there: it is a promise to the reader about their own account, sitting in a menu of things to read, and an absent row would read as an oversight.
video_consent_offeredgoes the other way. When a guild has no video policy the option is absent — not greyed — and one sentence beside the control says the server records audio only.This is the second case, twice over.
It is a form field. A dropdown exists to be chosen from and saved. A row inside one that a save would refuse is not a promise; it is a trap under the cursor of somebody deciding, and the lesson "this console's controls do not all work" is learned once and applied to every control afterwards.
And more decisively: the console cannot see the deployment's registry.
supported_formats()lives in the API process, no endpoint reads it out, andapiError.sanitiseFetchErrordeliberately keeps nothing of a failed response but its status — so thesupportedlist a 400 carries never reaches a page, and cannot without undoing the reason that module exists. APDF — coming soonrow would therefore be this console asserting a fact about a build it cannot inspect. Worse:export_formatspromises that addingpdfto that registry needs no change anywhere else, and the console would go on calling it unavailable until somebody remembered to edit a second list.So
EXPORT_FORMATSis stated as a belief about the deployment, and the console defers to the stored value whenever the two disagree:outline—directory.ts's rule for an unresolved channel id, applied to a word instead of a snowflake.primaryTargetdoes not filter on the format list. Whether this deployment can publishpdfis the deployment's answer, and skipping a row because the console does not know the word would name the wrong destination as the announced one on exactly the deployment where it matters.The credential
The API's shape is the argument, and this follows it rather than working around it.
ExportTargethashas_secretand nothing else;ExportTargetStore.secret_foris deliberately not on the port a console handler can reach;PUT .../secretis a route of its own because aPUTon the destination that also wrote the credential would clear it on every rename.There is no credential field on the destination form, and
TargetDrafthas no field for one. The mistake is unrepresentable rather than merely avoided: there is nothing a carelessv-modelcould bind to.ExportTargetSecretis the control that writes it, and it does four things:{"secret": null}is a different request, and it is irreversible in the strongest sense available — nothing anywhere can read back what was there to put it back. An empty box is refused on submit, so "save nothing" cannot read as a way to clear one.secret_forexists and no publish path calls it. A control that let somebody believe their token was in use would be a control that quietly does nothing, so it says so — and it is still rendered on every destination, becausehas_secretis a real state and a control that hid itself would leave a stored credential nobody could clear.What each format needs
The form shows what this format needs rather than the union of every field.
outlinedocument_target, overGET /api/outline/collectionsmarkdownhtmlWhich of the two a format wants is
FormatSpec.targetKind, so adding a format stays adding an entry rather than a branch in a template. Both address patterns are the API's owntarget_pattern, anchored the wayfullmatchanchors them — a second copy of a rule, and a courtesy rather than a control in exactly the sensenavigation.tsmeans it: the API refuses regardless, and what this buys is that the reason is legible beside the field instead of arriving as a bare 400 the console has stripped of its explanation.The collections endpoint is decoration, as it is on Bot Settings: if it refuses, the field falls back to asking for a collection id and one sentence says why. A configuration page that cannot be used because a name list is down would be worse than one that asks for ids. A stored id with no row in the copy stays in the list rather than being dropped.
configis carried through untouched and never edited. No built format needs anything in it, and a JSON box on a page nobody has a reason to type JSON into is a way to break a destination rather than to configure one.A name cannot be changed here, because the API cannot change one.
update_targetreads the name from the stored row and ignores the body, so that "publish to Wiki" cannot stop meaning what the person who set it up thought it meant. An editable box over a value that will be discarded is the interface promising something the API refuses, so on an edit the name is read-only and one sentence says that renaming is adding a destination and removing this one.Where a meeting actually went
On the recording page's The meeting tab, in a section of its own. The listing is fetched alongside the session and the transcript, swallowed the same way — and as
nullrather than[], because "nothing was published" and "where this went could not be read" are different answers and drawing the second as the first would tell somebody their meeting reached nowhere when in fact nobody asked.A participant cannot count a guild's destinations — that listing is administrator-only and answers 404 to them — so this page can never say "one of three failed". What it can see is a disagreement between two answers, and one of them is exact:
document_urlis stamped from the primary alone, and the listing carries every destination that succeeded. So:document_urldocument_targetpublish, which records no row — every meeting from before destinations existed. One link, and a sentence saying why there is nothing to listThe fourth row is the partial publish, and it is a fact rather than an inference:
publish_sessionsurvives each destination's own failure and records only the ones that succeeded.Nothing diagnoses. These sentences say which two answers disagree and stop. They never call a destination broken, because a destination the retry sweep has not reached yet and one that will never work look identical from here — the discipline
utils/queue.tsalready applies to its derived figures, and there is a test asserting the wordsfailed,brokenanderrorappear in none of them.A row whose destination has since been removed keeps its link and says so:
target_idisON DELETE SET NULLbecause removing a destination is "stop publishing here", never "forget what was published".document_target, said where it will be readThe scope asked for this to be stated where somebody configuring destinations will read it, and that is where the weight is: a section at the foot of the page whose sentence is a function of that guild's state, because the fallback is genuinely in force for one guild and genuinely dead for the next. Nothing enabled → it still publishes where
document_targetpoints, and switching one on here takes over completely. Something enabled → that setting is no longer used for it, and these replace it rather than adding to it. A link to Bot Settings under both.The reciprocal note goes on Bot Settings, through
fieldHints, which is where that page already puts what a key means. It is one entry in a table rather than a branch, and it is English like the rest of that module's hints for the reason those are — that page awaits the sweepi18n/README.mddescribes. It cannot say which of the two is in force, because a hint under a settings key knows nothing about that guild's destinations; the Destinations page can, and does.Untouched
No Python. The queue page, the consents page and
recordings/index.vueare not opened. Guild onboarding and OAuth configuration belong to sibling branches. The recording page's tabs, its transport, its tag editor and its re-queue panel are unchanged — the protocol link grew into a section beside the "About this meeting" one, and nothing else on that tab moved.New strings go through i18n in both locale files under
admin.destinations.*, complete from the start because the page is new. Three of them are not there: a format's name lives incommon.*, because the recording page has to say the same three words beside a published document and two copies of a word are two words that drift.test/i18n.spec.ts's allowlist grows by two —NameandFormat, which German borrowed unchanged — each with its reason.Pure Tailwind, no
<style>block, colours from role tokens, oneanimate-pulseskeleton carryingmotion-reduce:animate-none.Checks
vitest run1657 passed, 53 files (+79 tests, +2 files overmain's 1578/51) ·lint·typecheck·build— all clean, measured on the branch cut frommain.