diff --git a/decisions/2026-06-06-decommission-backend-guild-automation-execution-service.md b/decisions/2026-06-06-decommission-backend-guild-automation-execution-service.md new file mode 100644 index 000000000..6d77c5e8a --- /dev/null +++ b/decisions/2026-06-06-decommission-backend-guild-automation-execution-service.md @@ -0,0 +1,83 @@ +# ADR 2026-06-06 — Decommission backend GuildAutomationExecutionService + +**Status:** Accepted +**Via:** `/research-and-decide` (critic adjudicated delete-vs-retain; landed on delete — no flip) +**Relates to:** [2026-05-19-guild-automation-module-executors](2026-05-19-guild-automation-module-executors.md) (the migration this file is being superseded by) + +## Context + +The overengineering audit (2026-06-06) flagged `packages/backend/src/services/GuildAutomationExecutionService.ts` +(1,333 LOC) as the single largest removable file in the repo. Investigation made the call +non-trivial, so it went through research-and-decide. + +Verified current state: + +- **Zero production callers.** The only reference anywhere is its own unit test. The backend + route `routes/guildAutomation.ts` (`/automation/plan`, `/automation/apply`, `/capture`) now + imports the **shared** `guildAutomationService` → `GuildAutomationOrchestrator`, not this file. + The bot already deleted its equivalent. +- **The migration that supersedes it is incomplete.** The shared package wires **3 of 7** module + executors (autoMessages, moderation, reactionRoles). The other 4 — Roles, Channels, Onboarding, + CommandAccess — have no shared executor yet. +- This old file holds the **only** full implementations of those 4 modules' apply/remap logic + (`applyRolesAndChannels`, `applyOnboardingModule`, `pruneStaleRoles/Channels`, + `remapRolesSection`, `remapOnboardingSection`, `remapCommandAccessSection`, …). It also + instantiates the 3 new shared executors — i.e. it was the migration's bridge/staging ground, + now stranded with no caller. +- Its unit test still runs in CI, producing **green coverage for code nothing calls**. +- Solo maintainer; git history is intact and the file is recoverable via `git show :`. + +## Decision + +**Delete the file now** (do not retain until the migration completes), specifically: + +1. Delete `packages/backend/src/services/GuildAutomationExecutionService.ts` and its test + `packages/backend/tests/unit/services/GuildAutomationExecutionService.test.ts` in one PR + (per the repo's "remove the feature, sweep its tests in the same PR" convention). +2. Record the decommission here and cross-link from the migration ADR. The 4 un-migrated + modules' reference logic lives immutably in git; future executor PRs recover it via + `git show 71805799:packages/backend/src/services/GuildAutomationExecutionService.ts` + rather than from stranded live code. + +Rationale: the file is **already** dead (no callers), so deletion is runtime-safe — it changes +nothing at execution time. Remaining executors are ported **fresh per module**, not copy-pasted +from this file, so it is a _reference at best_, and git serves that better than drift-prone live +code. "Retain until migration completes" is a weak contract that historically becomes "retain +forever" on this repo (cf. the per-guild-toggle orphan that sat dead for 2 weeks — +[2026-05-19-retire-per-guild-feature-toggles](2026-05-19-retire-per-guild-feature-toggles.md)), +and the passing test is a false coverage signal that invites future devs to treat dead code as +load-bearing. + +## Alternatives considered + +- **Retain untouched until the migration reaches 7/7, then delete** — rejected. No runtime + benefit (already uncalled); high risk of becoming permanent if the migration stalls; the + stranded file drifts from the orchestrator and misleads. Git already preserves the reference. +- **Retain but quarantine** (rename `*.DEPRECATED.ts`, add `@deprecated` header, exclude its + test from CI) — rejected as a worse version of delete: keeps 1,333 LOC of inert code and the + confusion surface for a benefit (reference) that git already provides. Acceptable fallback + only if the revisit-trigger below fires. +- **Extract the 4 un-migrated modules' logic into a holding module now** — rejected as + premature; that work belongs in each module's executor PR, not a speculative pre-extraction. + +## Consequences + +- **Positive:** −1,333 LOC (+ its ~2,174-LOC test); removes a false coverage signal; removes a + drift/confusion source; signals "this path is obsolete, read the migration ADR + executor PRs." +- **Negative:** Porting the remaining 4 executors references git history instead of live code — + one extra `git show` per module (<5 min). Mitigated by recording the pre-deletion SHA here. +- **Neutral / out of scope:** Whether the 4 un-migrated modules currently apply at all through + the new orchestrator is a **separate possible functional gap** — this file's deletion neither + causes nor worsens it (the file has no callers). Tracked as a follow-up, not part of this PR. + +## Revisit when + +Re-open (and prefer the quarantine fallback) if **any** of: + +1. **No module-executor PR merges by 2026-06-20** → the migration has stalled; a live reference + may regain value over git archaeology. +2. A Roles/Channels/Onboarding/CommandAccess executor PR copies **2+ functions verbatim** from + the old file → it was a template, not just a reference; reconsider keeping it until 7/7. +3. The separate "do the 4 modules apply through the orchestrator at all?" follow-up reveals the + old service was the _de facto_ live apply path for them after all (contradicting the + zero-caller finding) → halt deletion, re-investigate. diff --git a/decisions/2026-06-06-guild-automation-migration-freeze-and-instrument.md b/decisions/2026-06-06-guild-automation-migration-freeze-and-instrument.md new file mode 100644 index 000000000..3d2ae25d3 --- /dev/null +++ b/decisions/2026-06-06-guild-automation-migration-freeze-and-instrument.md @@ -0,0 +1,81 @@ +# ADR 2026-06-06 — Freeze the Guild Automation executor migration; instrument usage before completing or descoping + +**Status:** Accepted +**Via:** `/research-and-decide` (critic adjudicated complete vs freeze vs descope vs remove; landed on freeze-as-holding-state + measure — no flip) +**Relates to / umbrella for:** PRD #1059 · [2026-05-19-guild-automation-module-executors](2026-05-19-guild-automation-module-executors.md) · [2026-06-06-decommission-backend-guild-automation-execution-service](2026-06-06-decommission-backend-guild-automation-execution-service.md) · [2026-06-06-web-guild-automation-apply-plan-only](2026-06-06-web-guild-automation-apply-plan-only.md) + +## Context + +Two prior decisions this session (decommission the dead 1,333-LOC backend service; make web +"apply" honest/plan-only) exposed a bigger question: **is the Guild Automation Module Executor +migration (PRD #1059) worth completing at all?** + +Verified state: + +- **Footprint ≈ 4,900 hand-written LOC** (≈5.5% of the repo): shared core 2,084 + (manifest/diff/3-of-7 executors/orchestrator/repository); backend 1,531 (incl. the 1,333-LOC + monolith already decided for deletion + a 198-LOC route); bot apply 548; frontend page+api 726. +- **Migration stalled at 3 of 7 executors**, all DB-only. The `DiscordWriteAdapter` / + `DiscordRestAdapter` seam — the linchpin that lets the _backend_ apply Discord-writing modules + and that collapses the bot/backend duplication — was **never built**. The 4 remaining modules + (Roles, Channels, Onboarding, CommandAccess) are mostly Discord-writing and need it. ADR estimate + to finish: "≥4 PRs"; the adapter design carries flagged coupling risk and has had **no design review**. +- A **working apply path exists**: the bot `/guildconfig apply` command. Only the web/backend path + is hollow (now being made plan-only). +- The subsystem has been a recurring **maintenance sink** (a 1,333-LOC dead service; a P1 + misleading-apply bug; a stalled migration). +- **Usage is UNMEASURED.** No telemetry on automation apply/plan. The web page is prominent in the + sidebar (discoverable) but we do not know how many guilds use it. "Near-zero usage" is an + **assumption**, and the choice between completing (A) and descoping/removing (C/D) hinges on it. + +## Decision + +**Freeze the migration as the holding state, fix the accumulated debt, and instrument usage. Defer +the A-vs-C/D choice to explicit data/design gates — do not make a large irreversible bet blind.** + +Holding-state actions (small, mostly already decided): + +1. Implement [2026-06-06-decommission-backend-guild-automation-execution-service] — delete the dead + 1,333-LOC monolith + its test. +2. Implement [2026-06-06-web-guild-automation-apply-plan-only] — honest web UX + plan-only run record + (kills the false "completed"/autoApplied audit trail). +3. **NEW — instrument usage:** add lightweight per-guild counters on the web `/automation/plan` and + `/automation/apply` attempts and the bot `/guildconfig apply` subcommand (count + guildId, no PII). + This is the missing fact that should drive A-vs-C/D. Cheap (≈ middleware + one log/metric). +4. Reflect the freeze on PRD #1059 (status: frozen pending the gates below); do not open further + executor PRs until a gate fires. + +This explicitly does **not** complete the migration (A) now, nor descope/remove (C/D) now. + +## Alternatives considered + +- **A — Complete the migration now.** Rejected as the immediate move: ≥4 PRs of speculative effort + (incl. designing the never-built adapter, with flagged coupling risk and no design review) for a + feature of **unknown demand**, in a solo-maintainer context. Kept as the destination if a gate fires. +- **C — Descope/simplify (rip out web+backend orchestration + parity/cutover, keep bot path).** + Rejected _now_: parity/cutover is entangled (non-trivial surgery), and removing a shipped, + discoverable web feature on an **assumed**-low usage could destroy real value. Becomes the likely + path if telemetry shows the web path is unused. +- **D — Remove the whole feature (bot + web).** Rejected: the bot `/guildconfig apply` is a working, + shipped feature; removing it blind is user-hostile. Only reconsider if telemetry shows ~zero usage + of _both_ paths. + +## Consequences + +- **Positive:** stops the bleeding cheaply (delete dead code, honest UX, telemetry) without betting + 4+ PRs on unknown demand; converts a speculative A-vs-C/D argument into a data-driven one; keeps the + working bot path intact. +- **Negative:** the subsystem stays half-built — web apply is plan-only and ~3,500 LOC of frozen + machinery is carried until a gate fires. "Freeze" can decay into "permanent" (the explicit gates + + the 2026-07-06 sunset trigger below exist to prevent that). +- **Neutral:** completing the migration remains fully possible; nothing here forecloses A. + +## Revisit when (the gates that resolve the deferred A-vs-C/D) + +1. **Usage telemetry shows >5% of active guilds use the web apply/plan path in any 7-day window** → + escalate **A**: do the `DiscordWriteAdapter` design review, then build the seam + 4 executors + wire + backend/web apply. +2. **A `DiscordWriteAdapter` design review (pre-mortem + adapter test plan) is completed and passes** → + confidence is high enough to proceed with **A** without waiting on usage data. +3. **Neither gate fires by 2026-07-06 (30 days)** → close PRD #1059 as deferred and run a focused + **C-vs-D** decision (descope vs remove) using the telemetry collected by then. diff --git a/decisions/2026-06-06-web-guild-automation-apply-plan-only.md b/decisions/2026-06-06-web-guild-automation-apply-plan-only.md new file mode 100644 index 000000000..c9649b404 --- /dev/null +++ b/decisions/2026-06-06-web-guild-automation-apply-plan-only.md @@ -0,0 +1,82 @@ +# ADR 2026-06-06 — Web Guild Automation "apply" is plan-only until the executor migration lands + +**Status:** Accepted +**Via:** `/research-and-decide` (critic adjudicated A vs B; landed on B + a data-integrity fix — no flip) +**Relates to:** [2026-05-19-guild-automation-module-executors](2026-05-19-guild-automation-module-executors.md) · PRD #1059 (complete the executors) · [2026-06-06-decommission-backend-guild-automation-execution-service](2026-06-06-decommission-backend-guild-automation-execution-service.md) + +## Context + +Confirmed P1, found while investigating the decommission ADR's "possible functional gap": + +The web dashboard's Guild Automation page has **Apply** + **Reconcile** buttons. `Apply` +calls `POST /automation/apply` → `GuildAutomationOrchestrator.createApplyRun()`, which: +computes a plan, writes a `GuildAutomationRun` with **`status: 'completed'`** and a populated +**`autoAppliedOperations`** list, and returns — **without invoking any executor or mutating +the Discord guild / settings tables** (verified: zero `.apply()` calls anywhere in +`shared/services/guildAutomation/`; the repository does only Prisma bookkeeping). The web UI +then toasts **"Changes applied."** + +So the web Apply is a **no-op that reports success**, and the run record is a **false audit +trail**. The only code that actually mutates a guild is the bot's `/guildconfig apply` slash +command (`bot/utils/guildAutomation/applyPlan.ts`, direct discord.js writes + 3 shared +executors); there is **no bridge** from a backend run to bot application. + +Root cause: the Module Executor migration replaced the backend's executing +`GuildAutomationExecutionService` with a **plan-only** orchestrator and relocated execution to +the bot, but the **web UI was never updated**. The intended end-state (ADR 2026-05-19) _does_ +have the backend applying via a `DiscordWriteAdapter` → `DiscordRestAdapter` seam — but that +seam **does not exist yet** (never built), and 4 of 7 executors (Roles, Channels, Onboarding, +CommandAccess — the Discord-writing ones that need the adapter) are unbuilt. ADR estimate to +finish: "≥4 PRs" (PRD #1059). + +## Decision + +**B — make the web UX honest now; defer real web apply (A) to the executor migration.** + +Immediate fix (one small PR): + +1. **Frontend:** relabel the web **Apply** action away from claiming application (e.g. "Record + plan" / present it as a dry-run + parity record); replace the `"Changes applied."` toast with + one that states a plan was recorded and that applying happens via `/guildconfig apply` in + Discord. Same for **Reconcile**. +2. **Backend/shared (data-integrity, do regardless of A/B):** stop recording a **false + completion** — `createApplyRun` must not mark the run `completed` with `autoAppliedOperations` + populated when nothing executed. Record it as **plan-only / not-applied** (a planned/queued + status or an explicit `applied: false`, per the run model) with `autoAppliedOperations: []`. +3. Verify the bot `/guildconfig apply` path and `getStatus`/`listRuns` displays are unaffected. + +Real backend-driven web apply (**Option A**) is the _correct end-state_ and is **deferred to +PRD #1059**: it requires building the `DiscordWriteAdapter` + backend `DiscordRestAdapter` and +wiring `createApplyRun` to invoke executors. When that lands, re-enable the web Apply label/toast. + +## Alternatives considered + +- **A — make web apply real now.** Rejected as the _immediate_ fix: it means finishing a + multi-PR migration (the adapter seam + 4 executors, "≥4 PRs") just to correct a misleading + toast, for a niche feature with ~near-zero web usage. It is the right _destination_ (kept as + the deferred end-state), not a P1 hotfix. Violates the no-big-bang-mid-hotfix instinct. +- **Hybrid — wire only the 3 built DB-only executors into backend apply now.** Rejected, worse + than the status quo: web apply would mutate 3 of 7 modules and silently skip Roles/Channels/ + Onboarding/CommandAccess, splitting the lie across modules and making "what actually changed?" + harder to reason about. + +## Consequences + +- **Positive:** kills the misleading "Changes applied" success and the false `autoAppliedOperations` + audit trail in hours, not weeks; keeps PRD #1059 independent; matches current reality (bot is + the working apply path); fully reversible when A lands. +- **Negative:** the web's headline "apply" capability is openly downgraded to plan/record until + the migration completes — users must apply via the Discord `/guildconfig` command. +- **Neutral:** a back-fill of any pre-existing falsely-`completed` web runs is _optional_ — given + near-zero usage it's likely unnecessary; decide when implementing. + +## Revisit when + +- **PRD #1059 reaches the adapter-seam stage** → implement Option A: build + `DiscordWriteAdapter`/`DiscordRestAdapter`, wire `createApplyRun` to invoke executors, restore + the web Apply label/toast. (This ADR's stopgap ends here.) +- **Web-apply usage proves non-trivial** (e.g. >5% of guilds attempt it) → escalate A's priority + ahead of the rest of #1059. +- **The `DiscordWriteAdapter` design reveals insurmountable Discord-state coupling** → re-litigate + whether the backend should apply at all (vs web → bot-bridge), per ADR 2026-05-19's own + no-go condition. diff --git a/packages/backend/src/middleware/validate.ts b/packages/backend/src/middleware/validate.ts index 2cb14e647..22a90c718 100644 --- a/packages/backend/src/middleware/validate.ts +++ b/packages/backend/src/middleware/validate.ts @@ -6,6 +6,14 @@ import { z } from 'zod' // preserves caller-side inference exactly. type Schema = z.ZodType +function stripUnknownFields(data: object, allowedKeys: Set): void { + for (const key of Object.keys(data)) { + if (!allowedKeys.has(key)) { + delete (data as Record)[key] + } + } +} + export function validateBody(schema: Schema) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body as unknown) @@ -33,14 +41,11 @@ export function validateQuery(schema: Schema) { return res.status(400).json({ error: 'Validation failed', errors }) } - // Strip unknown fields by reconstructing query with only schema keys - const dataKeys = new Set(Object.keys(result.data as object)) - for (const key of Object.keys(req.query)) { - if (!dataKeys.has(key)) { - delete req.query[key] - } - } - // Assign validated data back (which includes transformations like coercion) + // Strip unknown fields and assign validated data back (which includes transformations like coercion) + stripUnknownFields( + req.query, + new Set(Object.keys(result.data as object)), + ) Object.assign(req.query, result.data as object) next() } @@ -57,14 +62,11 @@ export function validateParams(schema: Schema) { return res.status(400).json({ error: 'Validation failed', errors }) } - // Strip unknown fields by reconstructing params with only schema keys - const dataKeys = new Set(Object.keys(result.data as object)) - for (const key of Object.keys(req.params)) { - if (!dataKeys.has(key)) { - delete req.params[key] - } - } - // Assign validated data back (which includes transformations like coercion) + // Strip unknown fields and assign validated data back (which includes transformations like coercion) + stripUnknownFields( + req.params, + new Set(Object.keys(result.data as object)), + ) Object.assign(req.params, result.data as object) next() } diff --git a/packages/backend/src/routes/guildAutomation.ts b/packages/backend/src/routes/guildAutomation.ts index a70b72e99..5dcfb88d7 100644 --- a/packages/backend/src/routes/guildAutomation.ts +++ b/packages/backend/src/routes/guildAutomation.ts @@ -10,6 +10,8 @@ import { guildAutomationService, validateGuildAutomationManifest, } from '@lucky/shared/services' +import { guildAutomationUsageTotal } from '../utils/prometheus' +import { infoLog } from '@lucky/shared/utils' function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] @@ -23,6 +25,23 @@ function requireUserId(req: AuthenticatedRequest): string { return req.userId } +/** + * Records a Guild Automation usage attempt (per-guild, non-PII): increments the + * Prometheus counter and logs operation + guildId only. Called right after auth, + * before body validation, so attempts that later fail validation are still counted + * (unauthenticated 401s are not — those aren't real demand). Usage demand is the + * input the freeze-gate decision needs. The bot's `/guildconfig` command increments + * a matching counter (packages/bot/.../monitoring/prometheus.ts) so the demand + * signal spans both surfaces; sum the two at decision time. + */ +function recordAutomationUsage( + operation: 'plan' | 'apply' | 'reconcile', + guildId: string, +): void { + guildAutomationUsageTotal.inc({ operation }) + infoLog({ message: 'Guild Automation usage', data: { operation, guildId } }) +} + export function setupGuildAutomationRoutes(app: Express): void { app.get( '/api/guilds/:guildId/automation/manifest', @@ -94,6 +113,7 @@ export function setupGuildAutomationRoutes(app: Express): void { asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) const userId = requireUserId(req) + recordAutomationUsage('plan', guildId) const body = s.guildAutomationRunBody.parse(req.body) const actualState = body.actualState ? validateGuildAutomationManifest(body.actualState) @@ -117,6 +137,7 @@ export function setupGuildAutomationRoutes(app: Express): void { asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) const userId = requireUserId(req) + recordAutomationUsage('apply', guildId) const body = s.guildAutomationRunBody.parse(req.body) const actualState = body.actualState ? validateGuildAutomationManifest(body.actualState) @@ -144,6 +165,7 @@ export function setupGuildAutomationRoutes(app: Express): void { asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) const userId = requireUserId(req) + recordAutomationUsage('reconcile', guildId) const body = s.guildAutomationRunBody.parse(req.body) const actualState = body.actualState ? validateGuildAutomationManifest(body.actualState) diff --git a/packages/backend/src/services/GuildAutomationExecutionService.ts b/packages/backend/src/services/GuildAutomationExecutionService.ts deleted file mode 100644 index 8716c3350..000000000 --- a/packages/backend/src/services/GuildAutomationExecutionService.ts +++ /dev/null @@ -1,1333 +0,0 @@ -import { - autoMessageService, - autoModService, - getModerationSettings, - guildAutomationService, - guildRoleAccessService, - reactionRolesService, - roleManagementService, - updateModerationSettings, - type GuildAutomationManifestDocument, - type GuildAutomationPlan, -} from '@lucky/shared/services' -import { - createAutoMessagesExecutor, - createModerationExecutor, - createReactionRolesExecutor, -} from '@lucky/shared/services/guildAutomation' -import { - type GuildAutomationRole, - type GuildAutomationChannel, - type GuildAutomationParity, -} from '@lucky/shared/services/guildAutomation/types' -import { debugLog, warnLog } from '@lucky/shared/utils' - -const DISCORD_API_BASE_URL = 'https://discord.com/api/v10' -const DEFAULT_SOURCE = 'discord-capture' - -type AutoModUpdatePayload = Parameters[1] -type ModerationUpdatePayload = Parameters[1] - -type DiscordGuildResponse = { - id: string - name: string -} - -type DiscordRoleResponse = { - id: string - name: string - color?: number - hoist?: boolean - mentionable?: boolean - permissions?: string - managed?: boolean -} - -type DiscordChannelResponse = { - id: string - name: string - type: number - parent_id?: string | null - topic?: string | null -} - -type DiscordEmojiResponse = { - id?: string | null - name?: string | null -} - -type DiscordOnboardingPromptOptionResponse = { - id?: string - title?: string - description?: string | null - channel_ids?: string[] - role_ids?: string[] - emoji?: DiscordEmojiResponse | null -} - -type DiscordOnboardingPromptResponse = { - id?: string - title?: string - single_select?: boolean - required?: boolean - in_onboarding?: boolean - type?: number - options?: DiscordOnboardingPromptOptionResponse[] -} - -type DiscordOnboardingResponse = { - enabled?: boolean - mode?: number - default_channel_ids?: string[] - prompts?: DiscordOnboardingPromptResponse[] -} - -type RoleRemap = Map -type ChannelRemap = Map -type ManifestRoles = NonNullable -type ManifestRole = ManifestRoles['roles'][number] -type ManifestChannel = ManifestRoles['channels'][number] - -const SUPPORTED_CHANNEL_TYPES = new Set([0, 2, 4, 5, 13, 15]) - -export class GuildAutomationExecutionError extends Error { - constructor( - message: string, - public readonly statusCode = 500, - ) { - super(message) - this.name = 'GuildAutomationExecutionError' - } -} - -export function normalizeName(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, ' ') -} - -export function asObject(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - - return value as Record -} - -export function toAutoModPayload( - value: GuildAutomationManifestDocument['moderation'] extends { - automod?: infer T - } - ? T - : unknown, -): AutoModUpdatePayload | null { - return asObject(value) ? (value as AutoModUpdatePayload) : null -} - -export function toModerationPayload( - value: GuildAutomationManifestDocument['moderation'] extends { - moderationSettings?: infer T - } - ? T - : unknown, -): ModerationUpdatePayload | null { - return asObject(value) ? (value as ModerationUpdatePayload) : null -} - -export function isExpectedDeleteError(error: unknown): boolean { - if (!(error instanceof GuildAutomationExecutionError)) { - return false - } - - return error.statusCode === 403 || error.statusCode === 404 -} - -export function isOnboardingUnavailable(error: unknown): boolean { - if (!(error instanceof GuildAutomationExecutionError)) { - return false - } - - return error.statusCode === 403 || error.statusCode === 404 -} - -export function mapChannelType(type: number): string { - switch (type) { - case 4: - return 'GuildCategory' - case 2: - return 'GuildVoice' - case 5: - return 'GuildAnnouncement' - case 15: - return 'GuildForum' - case 13: - return 'GuildStageVoice' - default: - return 'GuildText' - } -} - -export function toDiscordChannelType(type: string): number { - switch (type) { - case 'GuildCategory': - return 4 - case 'GuildVoice': - return 2 - case 'GuildAnnouncement': - return 5 - case 'GuildForum': - return 15 - case 'GuildStageVoice': - return 13 - default: - return 0 - } -} - -function shouldApplyModule( - plan: GuildAutomationPlan, - module: GuildAutomationPlan['operations'][number]['module'], - allowProtected: boolean, -): boolean { - return plan.operations.some( - (operation) => - operation.module === module && - (allowProtected || operation.protected === false), - ) -} - -function defaultParityChecklist() { - return [ - { - key: 'onboarding-native', - label: 'Native onboarding is configured in Lucky manifest', - done: false, - }, - { - key: 'moderation-parity', - label: 'Moderation and automod parity verified', - done: false, - }, - { - key: 'roles-parity', - label: 'Roles/channels parity verified in shadow mode', - done: false, - }, - { - key: 'external-bots-removed', - label: 'Legacy bot permissions/invites removed', - done: false, - }, - ] -} - -type RemapFn = (id: string | undefined | null) => string | undefined | null - -function remapRolesSection( - next: GuildAutomationManifestDocument, - remapRole: RemapFn, - remapChannel: RemapFn, -): void { - if (!next.roles) return - next.roles.roles = next.roles.roles.map((role) => ({ - ...role, - id: remapRole(role.id) ?? role.id, - })) - next.roles.channels = next.roles.channels.map((channel) => ({ - ...channel, - id: remapChannel(channel.id) ?? channel.id, - parentId: remapChannel(channel.parentId) ?? null, - })) -} - -function remapOnboardingSection( - next: GuildAutomationManifestDocument, - remapRole: RemapFn, - remapChannel: RemapFn, -): void { - if (!next.onboarding) return - next.onboarding.defaultChannelIds = next.onboarding.defaultChannelIds - .map((id) => remapChannel(id)) - .filter((id): id is string => Boolean(id)) - next.onboarding.prompts = next.onboarding.prompts.map((prompt) => ({ - ...prompt, - options: prompt.options.map((option) => ({ - ...option, - channelIds: option.channelIds - ?.map((id) => remapChannel(id)) - .filter((id): id is string => Boolean(id)), - roleIds: option.roleIds - ?.map((id) => remapRole(id)) - .filter((id): id is string => Boolean(id)), - })), - })) -} - -function remapModerationSection( - next: GuildAutomationManifestDocument, - remapRole: RemapFn, - remapChannel: RemapFn, -): void { - if (next.moderation?.automod) { - next.moderation.automod.exemptRoles = - next.moderation.automod.exemptRoles - ?.map((id) => remapRole(id)) - .filter((id): id is string => Boolean(id)) - next.moderation.automod.exemptChannels = - next.moderation.automod.exemptChannels - ?.map((id) => remapChannel(id)) - .filter((id): id is string => Boolean(id)) - } - if (next.moderation?.moderationSettings) { - const ms = next.moderation.moderationSettings - ms.muteRoleId = remapRole(ms.muteRoleId) ?? null - ms.modRoleIds = ms.modRoleIds - ?.map((id) => remapRole(id)) - .filter((id): id is string => Boolean(id)) - ms.adminRoleIds = ms.adminRoleIds - ?.map((id) => remapRole(id)) - .filter((id): id is string => Boolean(id)) - } -} - -function remapAutoMessagesSection( - next: GuildAutomationManifestDocument, - remapChannel: RemapFn, -): void { - if (next.automessages?.welcome) { - next.automessages.welcome.channelId = - remapChannel(next.automessages.welcome.channelId) ?? undefined - } - if (next.automessages?.leave) { - next.automessages.leave.channelId = - remapChannel(next.automessages.leave.channelId) ?? undefined - } -} - -function remapReactionRolesSection( - next: GuildAutomationManifestDocument, - remapRole: RemapFn, - remapChannel: RemapFn, -): void { - if (!next.reactionroles) return - next.reactionroles.messages = next.reactionroles.messages?.map( - (message) => ({ - ...message, - channelId: remapChannel(message.channelId) ?? undefined, - mappings: message.mappings?.map((mapping) => ({ - ...mapping, - roleId: remapRole(mapping.roleId) ?? mapping.roleId, - })), - }), - ) - next.reactionroles.exclusiveRoles = next.reactionroles.exclusiveRoles?.map( - (item) => ({ - roleId: remapRole(item.roleId) ?? item.roleId, - excludedRoleId: - remapRole(item.excludedRoleId) ?? item.excludedRoleId, - }), - ) -} - -function remapCommandAccessSection( - next: GuildAutomationManifestDocument, - remapRole: RemapFn, -): void { - if (!next.commandaccess) return - next.commandaccess.grants = next.commandaccess.grants.map((grant) => ({ - ...grant, - roleId: remapRole(grant.roleId) ?? grant.roleId, - })) -} - -class GuildAutomationExecutionService { - private readonly autoMessagesExecutor = createAutoMessagesExecutor({ - autoMessageService, - }) - - private readonly moderationExecutor = createModerationExecutor({ - port: { - updateAutoModSettings: async (guildId, settings) => { - const payload = toAutoModPayload(settings) - if (payload) - await autoModService.updateSettings(guildId, payload) - }, - updateModerationSettings: async (guildId, settings) => { - const payload = toModerationPayload(settings) - if (payload) await updateModerationSettings(guildId, payload) - }, - }, - }) - - private readonly reactionRolesExecutor = createReactionRolesExecutor({ - port: { - listExclusiveRoles: (guildId) => - roleManagementService.listExclusiveRoles(guildId) as Promise< - { roleId: string; excludedRoleId: string }[] - >, - removeExclusiveRole: (guildId, roleId, excludedRoleId) => - roleManagementService.removeExclusiveRole( - guildId, - roleId, - excludedRoleId, - ), - setExclusiveRole: (guildId, roleId, excludedRoleId) => - roleManagementService.setExclusiveRole( - guildId, - roleId, - excludedRoleId, - ), - }, - }) - - private getBotToken(): string { - const token = process.env.DISCORD_TOKEN?.trim() - - if (!token) { - throw new GuildAutomationExecutionError( - 'DISCORD_TOKEN is required for automation execution', - 503, - ) - } - - return token - } - - private async discordRequest(params: { - token: string - endpoint: string - method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' - body?: Record - }): Promise { - const method = params.method ?? 'GET' - const url = `${DISCORD_API_BASE_URL}${params.endpoint}` - - let response: Response - try { - response = await fetch(url, { - method, - headers: { - Authorization: `Bot ${params.token}`, - 'Content-Type': 'application/json', - }, - body: params.body ? JSON.stringify(params.body) : undefined, - }) - } catch (error) { - debugLog({ - message: 'Discord API fetch failed', - data: { method, endpoint: params.endpoint }, - error, - }) - throw new GuildAutomationExecutionError( - `Discord request failed for ${method} ${params.endpoint}`, - 502, - ) - } - - if (!response.ok) { - const responseBody = await response.text() - throw new GuildAutomationExecutionError( - `Discord request failed for ${method} ${params.endpoint}: ${response.status} ${responseBody}`, - response.status, - ) - } - - if (response.status === 204) { - return undefined as T - } - - return (await response.json()) as T - } - - private toOnboardingManifest( - onboarding: DiscordOnboardingResponse | null, - ): GuildAutomationManifestDocument['onboarding'] { - if (!onboarding) { - return undefined - } - - return { - enabled: onboarding.enabled ?? false, - mode: onboarding.mode ?? 0, - defaultChannelIds: onboarding.default_channel_ids ?? [], - prompts: (onboarding.prompts ?? []).map((prompt) => ({ - id: prompt.id, - title: prompt.title ?? '', - singleSelect: prompt.single_select, - required: prompt.required, - inOnboarding: prompt.in_onboarding, - type: prompt.type, - options: (prompt.options ?? []).map((option) => ({ - id: option.id, - title: option.title ?? '', - description: option.description ?? null, - channelIds: option.channel_ids ?? [], - roleIds: option.role_ids ?? [], - emoji: option.emoji?.id ?? option.emoji?.name ?? null, - })), - })), - } - } - - private async fetchOnboarding( - guildId: string, - token: string, - ): Promise { - try { - return await this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/onboarding`, - }) - } catch (error) { - if (isOnboardingUnavailable(error)) { - debugLog({ - message: - 'Onboarding unavailable while capturing guild automation state', - data: { guildId }, - error, - }) - return null - } - - throw error - } - } - - private normalizeRoleKey(role: { name: string }): string { - return normalizeName(role.name) - } - - private normalizeChannelKey(channel: { - name: string - type: string - parentId?: string | null - }): string { - const parentKey = channel.parentId ?? 'root' - return [normalizeName(channel.name), channel.type, parentKey].join('|') - } - - private resolveRoleTargetId(params: { - desiredRoleId: string - desiredRoleName: string - actualRoles: ManifestRole[] - usedActualRoleIds: Set - }): string | null { - const desiredById = params.actualRoles.find( - (role) => role.id === params.desiredRoleId, - ) - if (desiredById) { - return desiredById.id - } - - const desiredKey = this.normalizeRoleKey({ - name: params.desiredRoleName, - }) - const fallback = [...params.actualRoles] - .filter((role) => !params.usedActualRoleIds.has(role.id)) - .sort((a, b) => a.id.localeCompare(b.id)) - .find( - (role) => - this.normalizeRoleKey({ name: role.name }) === desiredKey, - ) - - return fallback?.id ?? null - } - - private resolveChannelTargetId(params: { - desiredChannelId: string - desiredChannelName: string - desiredChannelType: string - desiredParentId?: string | null - actualChannels: ManifestChannel[] - usedActualChannelIds: Set - }): string | null { - const byId = params.actualChannels.find( - (channel) => channel.id === params.desiredChannelId, - ) - - if (byId) { - return byId.id - } - - const desiredKey = this.normalizeChannelKey({ - name: params.desiredChannelName, - type: params.desiredChannelType, - parentId: params.desiredParentId, - }) - - const fallback = [...params.actualChannels] - .filter((channel) => !params.usedActualChannelIds.has(channel.id)) - .sort((a, b) => a.id.localeCompare(b.id)) - .find( - (channel) => - this.normalizeChannelKey({ - name: channel.name, - type: channel.type, - parentId: channel.parentId, - }) === desiredKey, - ) - - return fallback?.id ?? null - } - - private remapManifestEntityIds(params: { - manifest: GuildAutomationManifestDocument - roleRemap: RoleRemap - channelRemap: ChannelRemap - }): GuildAutomationManifestDocument { - const next = structuredClone(params.manifest) - const remapRole: RemapFn = (id) => - id ? (params.roleRemap.get(id) ?? id) : id - const remapChannel: RemapFn = (id) => - id ? (params.channelRemap.get(id) ?? id) : id - - remapRolesSection(next, remapRole, remapChannel) - remapOnboardingSection(next, remapRole, remapChannel) - remapModerationSection(next, remapRole, remapChannel) - remapAutoMessagesSection(next, remapChannel) - remapReactionRolesSection(next, remapRole, remapChannel) - remapCommandAccessSection(next, remapRole) - - return next - } - - private async applyOneRole(params: { - token: string - guildId: string - role: ManifestRole - actualRoles: ManifestRole[] - usedActualRoleIds: Set - roleRemap: RoleRemap - }): Promise { - const { - token, - guildId, - role, - actualRoles, - usedActualRoleIds, - roleRemap, - } = params - const targetRoleId = this.resolveRoleTargetId({ - desiredRoleId: role.id, - desiredRoleName: role.name, - actualRoles, - usedActualRoleIds, - }) - const payload = { - name: role.name, - color: role.color, - hoist: role.hoist, - mentionable: role.mentionable, - permissions: role.permissions, - } - let resolvedRoleId = targetRoleId - if (targetRoleId) { - await this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/roles/${targetRoleId}`, - method: 'PATCH', - body: payload, - }) - } else { - const created = await this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/roles`, - method: 'POST', - body: payload, - }) - resolvedRoleId = created.id - } - if (!resolvedRoleId) return - usedActualRoleIds.add(resolvedRoleId) - if (resolvedRoleId !== role.id) { - roleRemap.set(role.id, resolvedRoleId) - } - } - - private async applyOneChannel(params: { - token: string - guildId: string - channel: ManifestChannel - channelRemap: ChannelRemap - actualChannels: ManifestChannel[] - usedActualChannelIds: Set - }): Promise { - const { - token, - guildId, - channel, - channelRemap, - actualChannels, - usedActualChannelIds, - } = params - const remappedParentId = - (channel.parentId - ? channelRemap.get(channel.parentId) - : undefined) ?? channel.parentId - const targetChannelId = this.resolveChannelTargetId({ - desiredChannelId: channel.id, - desiredChannelName: channel.name, - desiredChannelType: channel.type, - desiredParentId: remappedParentId, - actualChannels, - usedActualChannelIds, - }) - const payload = { - name: channel.name, - type: toDiscordChannelType(channel.type), - parent_id: remappedParentId ?? null, - topic: channel.topic ?? null, - } - let resolvedChannelId = targetChannelId - if (targetChannelId) { - await this.discordRequest({ - token, - endpoint: `/channels/${targetChannelId}`, - method: 'PATCH', - body: payload, - }) - } else { - const created = await this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/channels`, - method: 'POST', - body: payload, - }) - resolvedChannelId = created.id - } - if (!resolvedChannelId) return - usedActualChannelIds.add(resolvedChannelId) - if (resolvedChannelId !== channel.id) { - channelRemap.set(channel.id, resolvedChannelId) - } - } - - private async pruneStaleRoles( - token: string, - guildId: string, - desiredRoles: ManifestRole[], - roleRemap: RoleRemap, - ): Promise { - const latestRoles = await this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/roles`, - }) - const desiredRoleIds = new Set( - desiredRoles.map((role) => roleRemap.get(role.id) ?? role.id), - ) - for (const role of latestRoles) { - if (role.id === guildId || role.managed) continue - if (desiredRoleIds.has(role.id)) continue - try { - await this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/roles/${role.id}`, - method: 'DELETE', - }) - } catch (error) { - if (!isExpectedDeleteError(error)) throw error - } - } - } - - private async pruneStaleChannels( - token: string, - guildId: string, - desiredChannels: ManifestChannel[], - channelRemap: ChannelRemap, - ): Promise { - const latestChannels = await this.discordRequest< - DiscordChannelResponse[] - >({ - token, - endpoint: `/guilds/${guildId}/channels`, - }) - const desiredChannelIds = new Set( - desiredChannels.map( - (channel) => channelRemap.get(channel.id) ?? channel.id, - ), - ) - for (const channel of latestChannels) { - if (desiredChannelIds.has(channel.id)) continue - try { - await this.discordRequest({ - token, - endpoint: `/channels/${channel.id}`, - method: 'DELETE', - }) - } catch (error) { - if (!isExpectedDeleteError(error)) throw error - } - } - } - - private async applyRolesAndChannels(params: { - token: string - guildId: string - desired: GuildAutomationManifestDocument - actual: GuildAutomationManifestDocument - allowProtected: boolean - roleRemap: RoleRemap - channelRemap: ChannelRemap - }): Promise { - const desiredRoles = params.desired.roles?.roles ?? [] - const desiredChannels = params.desired.roles?.channels ?? [] - const actualRoles = params.actual.roles?.roles ?? [] - const actualChannels = params.actual.roles?.channels ?? [] - const usedActualRoleIds = new Set() - const usedActualChannelIds = new Set() - - for (const role of desiredRoles) { - await this.applyOneRole({ - token: params.token, - guildId: params.guildId, - role, - actualRoles, - usedActualRoleIds, - roleRemap: params.roleRemap, - }) - } - - const sortedChannels = [...desiredChannels].sort((a, b) => { - const aPriority = a.type === 'GuildCategory' ? 0 : 1 - const bPriority = b.type === 'GuildCategory' ? 0 : 1 - if (aPriority !== bPriority) return aPriority - bPriority - return a.id.localeCompare(b.id) - }) - - for (const channel of sortedChannels) { - await this.applyOneChannel({ - token: params.token, - guildId: params.guildId, - channel, - channelRemap: params.channelRemap, - actualChannels, - usedActualChannelIds, - }) - } - - if (!params.allowProtected) return - - await this.pruneStaleRoles( - params.token, - params.guildId, - desiredRoles, - params.roleRemap, - ) - await this.pruneStaleChannels( - params.token, - params.guildId, - desiredChannels, - params.channelRemap, - ) - } - - private buildGuildAutomationManifest(data: { - guild: unknown & { id: string; name: string } - manifestRoles: unknown[] - manifestChannels: unknown[] - onboarding: unknown - manifest: unknown - automodSettings: Record | null - moderationSettings: Record | null - welcomeMessage: unknown - leaveMessage: unknown - reactionRoleMessages: unknown[] - exclusiveRoles: unknown[] - roleGrants: unknown[] - parity: unknown - }): GuildAutomationManifestDocument { - const { - guild, - manifestRoles, - manifestChannels, - onboarding, - manifest, - automodSettings, - moderationSettings, - welcomeMessage, - leaveMessage, - reactionRoleMessages, - exclusiveRoles, - roleGrants, - parity, - } = data - - const typedWelcomeMessage = welcomeMessage as unknown & { - enabled?: boolean - channelId?: string | null - message?: string | null - } - const typedLeaveMessage = leaveMessage as unknown & { - enabled?: boolean - channelId?: string | null - message?: string | null - } - - return { - version: - (manifest as unknown & { manifest?: { version?: number } }) - ?.manifest?.version ?? 1, - guild: { - id: guild.id, - name: guild.name, - }, - onboarding: this.toOnboardingManifest( - onboarding as DiscordOnboardingResponse | null, - ), - roles: { - roles: manifestRoles as GuildAutomationRole[], - channels: manifestChannels as GuildAutomationChannel[], - }, - moderation: { - automod: toAutoModPayload(automodSettings ?? null) ?? undefined, - moderationSettings: - toModerationPayload(moderationSettings ?? null) ?? - undefined, - }, - automessages: { - welcome: typedWelcomeMessage - ? { - enabled: typedWelcomeMessage.enabled, - channelId: typedWelcomeMessage.channelId ?? undefined, - message: typedWelcomeMessage.message ?? undefined, - } - : undefined, - leave: typedLeaveMessage - ? { - enabled: typedLeaveMessage.enabled, - channelId: typedLeaveMessage.channelId ?? undefined, - message: typedLeaveMessage.message ?? undefined, - } - : undefined, - }, - reactionroles: { - messages: reactionRoleMessages.map((message) => { - const msg = message as unknown & { - id: string - messageId: string - channelId: string - mappings: unknown[] - } - return { - id: msg.id, - messageId: msg.messageId, - channelId: msg.channelId, - mappings: (msg.mappings ?? []).map((mapping) => { - const map = mapping as unknown & { - roleId: string - label?: string - emoji?: string - style?: string - } - return { - roleId: map.roleId, - label: map.label ?? map.roleId, - emoji: map.emoji ?? undefined, - style: map.style ?? undefined, - } - }), - } - }), - exclusiveRoles: exclusiveRoles.map((item) => { - const typed = item as unknown & { - roleId: string - excludedRoleId: string - } - return { - roleId: typed.roleId, - excludedRoleId: typed.excludedRoleId, - } - }), - }, - commandaccess: { - grants: roleGrants.map((grant) => { - const g = grant as unknown & { - roleId: string - module: - | 'overview' - | 'settings' - | 'moderation' - | 'automation' - | 'music' - | 'integrations' - mode: 'view' | 'manage' - } - return { - roleId: g.roleId, - module: g.module, - mode: g.mode, - } - }), - }, - parity: parity as GuildAutomationParity | undefined, - source: DEFAULT_SOURCE, - capturedAt: new Date().toISOString(), - } - } - - async captureGuildAutomationState( - guildId: string, - ): Promise { - const token = this.getBotToken() - - const [ - guild, - roles, - channels, - onboarding, - manifest, - automodSettings, - moderationSettings, - welcomeMessage, - leaveMessage, - reactionRoleMessages, - exclusiveRoles, - roleGrants, - ] = (await Promise.all([ - this.discordRequest({ - token, - endpoint: `/guilds/${guildId}`, - }), - this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/roles`, - }), - this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/channels`, - }), - this.fetchOnboarding(guildId, token), - guildAutomationService.getManifest(guildId), - autoModService.getSettings(guildId), - getModerationSettings(guildId), - autoMessageService.getWelcomeMessage(guildId), - autoMessageService.getLeaveMessage(guildId), - reactionRolesService.listReactionRoleMessages(guildId), - roleManagementService.listExclusiveRoles(guildId), - guildRoleAccessService.listRoleGrants(guildId), - ])) as [ - DiscordGuildResponse, - DiscordRoleResponse[], - DiscordChannelResponse[], - DiscordOnboardingResponse | null, - unknown, - Record | null, - Record | null, - unknown, - unknown, - unknown[], - unknown[], - unknown[], - ] - - const manifestRoles = roles - .filter((role) => role.id !== guildId) - .map((role) => ({ - id: role.id, - name: role.name, - color: role.color, - hoist: role.hoist, - mentionable: role.mentionable, - permissions: role.permissions, - })) - - const manifestChannels = channels - .filter((channel) => SUPPORTED_CHANNEL_TYPES.has(channel.type)) - .map((channel) => ({ - id: channel.id, - name: channel.name, - type: mapChannelType(channel.type), - parentId: channel.parent_id ?? null, - topic: channel.topic ?? null, - readonly: false, - })) - - const parity = ( - manifest as unknown & { manifest?: { parity?: unknown } } - )?.manifest?.parity ?? { - shadowMode: true, - externalBots: [], - checklist: defaultParityChecklist(), - cutoverReady: false, - } - - return this.buildGuildAutomationManifest({ - guild, - manifestRoles, - manifestChannels, - onboarding, - manifest, - automodSettings: automodSettings as Record | null, - moderationSettings: moderationSettings as Record< - string, - unknown - > | null, - welcomeMessage, - leaveMessage, - reactionRoleMessages, - exclusiveRoles, - roleGrants, - parity, - }) - } - - private async applyOnboardingModule( - token: string, - guildId: string, - desired: GuildAutomationManifestDocument, - appliedModules: string[], - ): Promise { - const onboarding = desired.onboarding - if (!onboarding) return - await this.discordRequest({ - token, - endpoint: `/guilds/${guildId}/onboarding`, - method: 'PUT', - body: { - enabled: onboarding.enabled, - mode: onboarding.mode, - default_channel_ids: onboarding.defaultChannelIds, - prompts: onboarding.prompts.map((prompt) => ({ - id: prompt.id, - title: prompt.title, - single_select: prompt.singleSelect, - required: prompt.required, - in_onboarding: prompt.inOnboarding, - type: prompt.type, - options: prompt.options.map((option) => ({ - id: option.id ?? null, - title: option.title, - description: option.description ?? null, - channel_ids: option.channelIds, - role_ids: option.roleIds, - emoji: option.emoji ?? null, - })), - })), - }, - }) - appliedModules.push('onboarding') - } - - private async applyModerationModule( - guildId: string, - desired: GuildAutomationManifestDocument, - appliedModules: string[], - ): Promise { - const live = this.moderationExecutor.capture() - const diff = this.moderationExecutor.diff( - live, - desired.moderation ?? {}, - ) - const result = await this.moderationExecutor.apply(diff, { guildId }) - if (result.status !== 'success') { - warnLog({ - message: `Moderation executor apply: ${result.status}`, - data: { - guildId, - errors: - result.status === 'partial' ? result.errors : undefined, - }, - }) - } - appliedModules.push('moderation') - } - - private async applyAutoMessagesModule( - guildId: string, - desired: GuildAutomationManifestDocument, - appliedModules: string[], - ): Promise { - const live = await this.autoMessagesExecutor.capture({ guildId }) - const diff = this.autoMessagesExecutor.diff( - live, - desired.automessages ?? {}, - ) - const result = await this.autoMessagesExecutor.apply(diff, { guildId }) - if (result.status !== 'success') { - warnLog({ - message: `AutoMessages executor apply: ${result.status}`, - data: { - guildId, - errors: - result.status === 'partial' ? result.errors : undefined, - }, - }) - } - appliedModules.push('automessages') - } - - private async applyReactionRolesModule( - guildId: string, - desired: GuildAutomationManifestDocument, - appliedModules: string[], - skippedModules: string[], - ): Promise { - const live = await this.reactionRolesExecutor.capture({ guildId }) - const diff = this.reactionRolesExecutor.diff( - live, - desired.reactionroles ?? {}, - ) - const result = await this.reactionRolesExecutor.apply(diff, { guildId }) - if (result.status !== 'success') { - warnLog({ - message: `ReactionRoles executor apply: ${result.status}`, - data: { - guildId, - errors: 'errors' in result ? result.errors : result.error, - }, - }) - } - if ((desired.reactionroles?.messages?.length ?? 0) > 0) { - skippedModules.push( - 'reactionroles.messages requires manual message-template publish', - ) - } - appliedModules.push('reactionroles') - } - - private async applyCommandAccessModule( - guildId: string, - desired: GuildAutomationManifestDocument, - appliedModules: string[], - ): Promise { - await guildRoleAccessService.replaceRoleGrants( - guildId, - desired.commandaccess?.grants ?? [], - ) - appliedModules.push('commandaccess') - } - - async executeApplyPlan(params: { - guildId: string - plan: GuildAutomationPlan - desired: GuildAutomationManifestDocument - actual: GuildAutomationManifestDocument - allowProtected: boolean - }): Promise<{ - diagnostics: Record - remappedManifest?: GuildAutomationManifestDocument - }> { - const token = this.getBotToken() - const appliedModules: string[] = [] - const skippedModules: string[] = [] - const roleRemap: RoleRemap = new Map() - const channelRemap: ChannelRemap = new Map() - let effectiveDesired = params.desired - - if ( - shouldApplyModule(params.plan, 'onboarding', params.allowProtected) - ) { - await this.applyOnboardingModule( - token, - params.guildId, - effectiveDesired, - appliedModules, - ) - } - - if (shouldApplyModule(params.plan, 'roles', params.allowProtected)) { - await this.applyRolesAndChannels({ - token, - guildId: params.guildId, - desired: effectiveDesired, - actual: params.actual, - allowProtected: params.allowProtected, - roleRemap, - channelRemap, - }) - if (roleRemap.size > 0 || channelRemap.size > 0) { - effectiveDesired = this.remapManifestEntityIds({ - manifest: effectiveDesired, - roleRemap, - channelRemap, - }) - } - appliedModules.push('roles') - } - - if ( - shouldApplyModule(params.plan, 'moderation', params.allowProtected) - ) { - await this.applyModerationModule( - params.guildId, - effectiveDesired, - appliedModules, - ) - } - - if ( - shouldApplyModule( - params.plan, - 'automessages', - params.allowProtected, - ) - ) { - await this.applyAutoMessagesModule( - params.guildId, - effectiveDesired, - appliedModules, - ) - } - - if ( - shouldApplyModule( - params.plan, - 'reactionroles', - params.allowProtected, - ) - ) { - await this.applyReactionRolesModule( - params.guildId, - effectiveDesired, - appliedModules, - skippedModules, - ) - } - - if ( - shouldApplyModule( - params.plan, - 'commandaccess', - params.allowProtected, - ) - ) { - await this.applyCommandAccessModule( - params.guildId, - effectiveDesired, - appliedModules, - ) - } - - if (shouldApplyModule(params.plan, 'parity', params.allowProtected)) { - skippedModules.push('parity requires checklist/cutover workflow') - } - - const diagnostics: Record = { - appliedModules, - skippedModules, - roleIdRemaps: Object.fromEntries(roleRemap.entries()), - channelIdRemaps: Object.fromEntries(channelRemap.entries()), - } - - debugLog({ - message: 'Guild automation apply execution completed', - data: { - guildId: params.guildId, - appliedModules, - skippedModules, - remappedRoles: roleRemap.size, - remappedChannels: channelRemap.size, - }, - }) - - return { - diagnostics, - remappedManifest: - roleRemap.size > 0 || channelRemap.size > 0 - ? effectiveDesired - : undefined, - } - } -} - -export const guildAutomationExecutionService = - new GuildAutomationExecutionService() diff --git a/packages/backend/src/utils/prometheus.ts b/packages/backend/src/utils/prometheus.ts index 382dbbb89..3c367d05e 100644 --- a/packages/backend/src/utils/prometheus.ts +++ b/packages/backend/src/utils/prometheus.ts @@ -50,6 +50,17 @@ export const httpServerErrorsTotal = new Counter<'method' | 'route'>({ registers: [registry], }) +/** + * Counter: Guild Automation usage (plan/apply attempts), labelled by operation type and guild. + * Cardinality is bounded: we label by operation type (plan|apply|reconcile) and count total attempts. + */ +export const guildAutomationUsageTotal = new Counter<'operation'>({ + name: 'lucky_guild_automation_usage_total', + help: 'Count of Guild Automation plan/apply/reconcile attempts, labelled by operation type.', + labelNames: ['operation'], + registers: [registry], +}) + export async function renderMetrics(): Promise { return registry.metrics() } diff --git a/packages/backend/tests/integration/routes/toggles.test.ts b/packages/backend/tests/integration/routes/toggles.test.ts index 908a1121e..323384974 100644 --- a/packages/backend/tests/integration/routes/toggles.test.ts +++ b/packages/backend/tests/integration/routes/toggles.test.ts @@ -80,10 +80,10 @@ describe('Toggles Routes Integration', () => { const sharedServices = await import('@lucky/shared/services') featureToggleService = sharedServices.featureToggleService - featureToggleService.getGlobalToggleProvider.mockReturnValue('vercel') + featureToggleService.getGlobalToggleProvider.mockReturnValue('database') featureToggleService.getGlobalToggleStatus.mockResolvedValue({ enabled: true, - provider: 'vercel', + provider: 'database', writable: false, }) }) @@ -116,7 +116,7 @@ describe('Toggles Routes Integration', () => { .expect(200) expect(response.body).toHaveProperty('toggles') - expect(response.body).toHaveProperty('provider', 'vercel') + expect(response.body).toHaveProperty('provider', 'database') expect(response.body).toHaveProperty('writable', true) expect(response.body).toHaveProperty('sources') expect(mockFeatureToggleService.getAllToggles).toHaveBeenCalled() @@ -191,7 +191,7 @@ describe('Toggles Routes Integration', () => { expect(response.body).toEqual({ name: 'DOWNLOAD_VIDEO', enabled: true, - provider: 'vercel', + provider: 'database', writable: false, }) }) @@ -337,5 +337,4 @@ describe('Toggles Routes Integration', () => { }) }) }) - }) diff --git a/packages/backend/tests/unit/services/GuildAutomationExecutionService.test.ts b/packages/backend/tests/unit/services/GuildAutomationExecutionService.test.ts deleted file mode 100644 index a3423da6e..000000000 --- a/packages/backend/tests/unit/services/GuildAutomationExecutionService.test.ts +++ /dev/null @@ -1,2174 +0,0 @@ -import { describe, test, expect, beforeEach, jest } from '@jest/globals' - -const mockFetch = jest.fn() -global.fetch = mockFetch as any - -jest.mock('@lucky/shared/services', () => ({ - autoMessageService: { - getWelcomeMessage: jest.fn(), - getLeaveMessage: jest.fn(), - createMessage: jest.fn(), - updateMessage: jest.fn(), - }, - autoModService: { - getSettings: jest.fn(), - updateSettings: jest.fn(), - }, - getModerationSettings: jest.fn(), - updateModerationSettings: jest.fn(), - guildAutomationService: { - getManifest: jest.fn(), - }, - guildRoleAccessService: { - listRoleGrants: jest.fn(), - replaceRoleGrants: jest.fn(), - }, - reactionRolesService: { - listReactionRoleMessages: jest.fn(), - }, - roleManagementService: { - listExclusiveRoles: jest.fn(), - setExclusiveRole: jest.fn(), - removeExclusiveRole: jest.fn(), - }, -})) - -jest.mock('@lucky/shared/utils', () => ({ - debugLog: jest.fn(), - warnLog: jest.fn(), -})) - -import { - guildAutomationExecutionService, - GuildAutomationExecutionError, - normalizeName, - asObject, - toAutoModPayload, - toModerationPayload, - isExpectedDeleteError, - isOnboardingUnavailable, - mapChannelType, - toDiscordChannelType, -} from '../../../src/services/GuildAutomationExecutionService' -import { - autoMessageService, - autoModService, - getModerationSettings, - updateModerationSettings, - guildAutomationService, - guildRoleAccessService, - reactionRolesService, - roleManagementService, -} from '@lucky/shared/services' -import type { - GuildAutomationManifestDocument, - GuildAutomationPlan, -} from '@lucky/shared/services' -import { warnLog } from '@lucky/shared/utils' - -describe('GuildAutomationExecutionService', () => { - const GUILD_ID = '111111111111111111' - const TOKEN = 'test-token' - const ROLE_ID_1 = '222222222222222222' - const ROLE_ID_2 = '333333333333333333' - const CHANNEL_ID_1 = '444444444444444444' - const CHANNEL_ID_2 = '555555555555555555' - - beforeEach(() => { - jest.clearAllMocks() - process.env.DISCORD_TOKEN = TOKEN - mockFetch.mockReset() - }) - - describe('captureGuildAutomationState', () => { - test('should capture complete guild automation state', async () => { - const mockGuild = { - id: GUILD_ID, - name: 'Test Guild', - } - - const mockRoles = [ - { - id: GUILD_ID, - name: '@everyone', - color: 0, - hoist: false, - mentionable: false, - permissions: '0', - }, - { - id: ROLE_ID_1, - name: 'Admin', - color: 16711680, - hoist: true, - mentionable: true, - permissions: '8', - managed: false, - }, - ] - - const mockChannels = [ - { - id: CHANNEL_ID_1, - name: 'general', - type: 0, - parent_id: null, - topic: 'General chat', - }, - { - id: CHANNEL_ID_2, - name: 'voice', - type: 2, - parent_id: null, - topic: null, - }, - ] - - const mockOnboarding = { - enabled: true, - mode: 0, - default_channel_ids: [CHANNEL_ID_1], - prompts: [ - { - id: 'prompt1', - title: 'Welcome', - single_select: false, - required: true, - in_onboarding: true, - type: 0, - options: [ - { - id: 'option1', - title: 'Get role', - description: null, - channel_ids: [], - role_ids: [ROLE_ID_1], - emoji: { id: null, name: '👋' }, - }, - ], - }, - ], - } - - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => mockRoles, - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => mockChannels, - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => mockOnboarding, - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.guild.id).toBe(GUILD_ID) - expect(result.guild.name).toBe('Test Guild') - expect(result.roles?.roles).toHaveLength(1) - expect(result.roles?.roles[0].name).toBe('Admin') - expect(result.roles?.channels).toHaveLength(2) - expect(result.onboarding?.enabled).toBe(true) - expect(result.onboarding?.prompts).toHaveLength(1) - expect(result.source).toBe('discord-capture') - expect(result.capturedAt).toBeDefined() - }) - - test('should filter out @everyone role', async () => { - const mockGuild = { id: GUILD_ID, name: 'Test Guild' } - const mockRoles = [ - { id: GUILD_ID, name: '@everyone', color: 0 }, - { id: ROLE_ID_1, name: 'Admin', color: 0 }, - ] - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => mockRoles, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not Found', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.roles?.roles).toHaveLength(1) - expect(result.roles?.roles[0].id).toBe(ROLE_ID_1) - }) - - test('should filter unsupported channel types', async () => { - const mockGuild = { id: GUILD_ID, name: 'Test Guild' } - const mockChannels = [ - { id: CHANNEL_ID_1, name: 'text', type: 0 }, - { id: CHANNEL_ID_2, name: 'voice', type: 2 }, - { id: '666', name: 'private-thread', type: 12 }, - ] - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => mockChannels, - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not Found', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.roles?.channels).toHaveLength(2) - expect(result.roles?.channels.map((c) => c.type)).toEqual([ - 'GuildText', - 'GuildVoice', - ]) - }) - - test('should handle onboarding unavailable (403)', async () => { - const mockGuild = { id: GUILD_ID, name: 'Test Guild' } - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: false, - status: 403, - text: async () => 'Forbidden', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.onboarding).toBeUndefined() - }) - - test('should throw when DISCORD_TOKEN is missing', async () => { - delete process.env.DISCORD_TOKEN - - await expect( - guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ), - ).rejects.toThrow('DISCORD_TOKEN is required') - }) - - test('should throw on Discord API failure', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - text: async () => 'Internal Server Error', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - await expect( - guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ), - ).rejects.toThrow('Discord request failed') - }) - - test('should throw on fetch network error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - await expect( - guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ), - ).rejects.toThrow('Discord request failed') - }) - }) - - describe('executeApplyPlan', () => { - const createMinimalManifest = ( - overrides?: Partial, - ): GuildAutomationManifestDocument => ({ - version: 1, - guild: { id: GUILD_ID, name: 'Test Guild' }, - source: 'test', - capturedAt: new Date().toISOString(), - parity: { - shadowMode: true, - externalBots: [], - checklist: [], - cutoverReady: false, - }, - ...overrides, - }) - - const createMinimalPlan = ( - operations: GuildAutomationPlan['operations'], - ): GuildAutomationPlan => ({ - guildId: GUILD_ID, - status: 'pending', - operations, - summary: { added: [], changed: [], removed: [] }, - createdAt: new Date().toISOString(), - }) - - test('should apply onboarding module when plan includes it', async () => { - const desired = createMinimalManifest({ - onboarding: { - enabled: true, - mode: 0, - defaultChannelIds: [CHANNEL_ID_1], - prompts: [], - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'onboarding', - action: 'update', - protected: false, - description: 'Update onboarding', - }, - ]) - - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('onboarding') - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/onboarding'), - expect.objectContaining({ - method: 'PUT', - }), - ) - }) - - test('should skip protected module when allowProtected is false', async () => { - const desired = createMinimalManifest({ - onboarding: { - enabled: true, - mode: 0, - defaultChannelIds: [], - prompts: [], - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'onboarding', - action: 'delete', - protected: true, - description: 'Delete onboarding', - }, - ]) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).not.toContain( - 'onboarding', - ) - expect(mockFetch).not.toHaveBeenCalled() - }) - - test('should apply moderation settings', async () => { - const desired = createMinimalManifest({ - moderation: { - automod: { - enabled: true, - spamEnabled: true, - spamThreshold: 5, - exemptRoles: [], - exemptChannels: [], - }, - moderationSettings: { - enabled: true, - muteRoleId: null, - modRoleIds: [], - adminRoleIds: [], - }, - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'moderation', - action: 'update', - protected: false, - description: 'Update moderation', - }, - ]) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('moderation') - expect(autoModService.updateSettings).toHaveBeenCalledWith( - GUILD_ID, - expect.objectContaining({ - enabled: true, - spamEnabled: true, - }), - ) - expect(updateModerationSettings).toHaveBeenCalledWith( - GUILD_ID, - expect.objectContaining({ - enabled: true, - }), - ) - }) - - test('should warn and still record module when moderation executor returns partial', async () => { - jest.spyOn( - (guildAutomationExecutionService as any).moderationExecutor, - 'apply', - ).mockResolvedValue({ - status: 'partial', - applied: [], - errors: [{ opKind: 'automod', reason: 'service error' }], - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan: createMinimalPlan([ - { - module: 'moderation', - action: 'update', - protected: false, - description: 'Update moderation', - }, - ]), - desired: createMinimalManifest(), - actual: createMinimalManifest(), - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('moderation') - expect(warnLog).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Moderation executor apply: partial', - }), - ) - }) - - test('should warn and still record module when moderation executor returns failed', async () => { - jest.spyOn( - (guildAutomationExecutionService as any).moderationExecutor, - 'apply', - ).mockResolvedValue({ - status: 'failed', - error: 'automod service unavailable', - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan: createMinimalPlan([ - { - module: 'moderation', - action: 'update', - protected: false, - description: 'Update moderation', - }, - ]), - desired: createMinimalManifest(), - actual: createMinimalManifest(), - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('moderation') - expect(warnLog).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Moderation executor apply: failed', - }), - ) - }) - - test('should create welcome and leave auto messages', async () => { - const desired = createMinimalManifest({ - automessages: { - welcome: { - enabled: true, - channelId: CHANNEL_ID_1, - message: 'Welcome!', - }, - leave: { - enabled: true, - channelId: CHANNEL_ID_1, - message: 'Goodbye!', - }, - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'automessages', - action: 'create', - protected: false, - description: 'Create auto messages', - }, - ]) - - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('automessages') - expect(autoMessageService.createMessage).toHaveBeenCalledWith( - GUILD_ID, - 'welcome', - { message: 'Welcome!' }, - { channelId: CHANNEL_ID_1 }, - ) - expect(autoMessageService.createMessage).toHaveBeenCalledWith( - GUILD_ID, - 'leave', - { message: 'Goodbye!' }, - { channelId: CHANNEL_ID_1 }, - ) - }) - - test('should update existing auto messages', async () => { - const desired = createMinimalManifest({ - automessages: { - welcome: { - enabled: false, - channelId: CHANNEL_ID_2, - message: 'New welcome!', - }, - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'automessages', - action: 'update', - protected: false, - description: 'Update auto messages', - }, - ]) - - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue({ - id: 'msg1', - enabled: true, - channelId: CHANNEL_ID_1, - message: 'Old welcome', - }) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('automessages') - expect(autoMessageService.updateMessage).toHaveBeenCalledWith( - 'msg1', - { - message: 'New welcome!', - channelId: CHANNEL_ID_2, - enabled: false, - }, - ) - }) - - test('should apply reaction role rules', async () => { - const desired = createMinimalManifest({ - reactionroles: { - messages: [], - exclusiveRoles: [ - { - roleId: ROLE_ID_1, - excludedRoleId: ROLE_ID_2, - }, - ], - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'reactionroles', - action: 'update', - protected: false, - description: 'Update reaction roles', - }, - ]) - - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('reactionroles') - expect(roleManagementService.setExclusiveRole).toHaveBeenCalledWith( - GUILD_ID, - ROLE_ID_1, - ROLE_ID_2, - ) - }) - - test('should remove old exclusive role rules', async () => { - const desired = createMinimalManifest({ - reactionroles: { - messages: [], - exclusiveRoles: [], - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'reactionroles', - action: 'update', - protected: false, - description: 'Clean up reaction roles', - }, - ]) - - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([ - { roleId: ROLE_ID_1, excludedRoleId: ROLE_ID_2 }, - ]) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('reactionroles') - expect( - roleManagementService.removeExclusiveRole, - ).toHaveBeenCalledWith(GUILD_ID, ROLE_ID_1, ROLE_ID_2) - }) - - test('should apply command access grants', async () => { - const desired = createMinimalManifest({ - commandaccess: { - grants: [ - { - roleId: ROLE_ID_1, - module: 'moderation', - mode: 'allow', - }, - ], - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'commandaccess', - action: 'update', - protected: false, - description: 'Update command access', - }, - ]) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('commandaccess') - expect( - guildRoleAccessService.replaceRoleGrants, - ).toHaveBeenCalledWith(GUILD_ID, [ - { - roleId: ROLE_ID_1, - module: 'moderation', - mode: 'allow', - }, - ]) - }) - - test('should create new roles when no matching role exists', async () => { - const desired = createMinimalManifest({ - roles: { - roles: [ - { - id: ROLE_ID_1, - name: 'Admin', - color: 16711680, - hoist: true, - mentionable: true, - permissions: '8', - }, - ], - channels: [], - }, - }) - - const actual = createMinimalManifest({ - roles: { - roles: [], - channels: [], - }, - }) - - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'create', - protected: false, - description: 'Create roles', - }, - ]) - - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - id: 'new-role-id', - name: 'Admin', - }), - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('roles') - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/guilds/${GUILD_ID}/roles`), - expect.objectContaining({ - method: 'POST', - }), - ) - }) - - test('should update existing roles by ID', async () => { - const desired = createMinimalManifest({ - roles: { - roles: [ - { - id: ROLE_ID_1, - name: 'Admin Updated', - color: 255, - hoist: false, - mentionable: false, - permissions: '8', - }, - ], - channels: [], - }, - }) - - const actual = createMinimalManifest({ - roles: { - roles: [ - { - id: ROLE_ID_1, - name: 'Admin', - color: 0, - hoist: true, - mentionable: true, - permissions: '8', - }, - ], - channels: [], - }, - }) - - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'update', - protected: false, - description: 'Update roles', - }, - ]) - - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('roles') - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/roles/${ROLE_ID_1}`), - expect.objectContaining({ - method: 'PATCH', - }), - ) - }) - - test('should create channels and categories', async () => { - const desired = createMinimalManifest({ - roles: { - roles: [], - channels: [ - { - id: CHANNEL_ID_1, - name: 'general', - type: 'GuildText', - parentId: null, - topic: 'Welcome', - readonly: false, - }, - ], - }, - }) - - const actual = createMinimalManifest({ - roles: { - roles: [], - channels: [], - }, - }) - - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'create', - protected: false, - description: 'Create channels', - }, - ]) - - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - id: 'new-channel-id', - name: 'general', - }), - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.appliedModules).toContain('roles') - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/guilds/${GUILD_ID}/channels`), - expect.objectContaining({ - method: 'POST', - }), - ) - }) - - test('should remap role and channel IDs when created', async () => { - const OLD_ROLE_ID = 'old-role-id' - const NEW_ROLE_ID = 'new-role-id' - - const desired = createMinimalManifest({ - roles: { - roles: [ - { - id: OLD_ROLE_ID, - name: 'Admin', - color: 0, - hoist: false, - mentionable: false, - permissions: '8', - }, - ], - channels: [], - }, - moderation: { - moderationSettings: { - enabled: true, - muteRoleId: OLD_ROLE_ID, - modRoleIds: [OLD_ROLE_ID], - adminRoleIds: [], - }, - }, - }) - - const actual = createMinimalManifest({ - roles: { - roles: [], - channels: [], - }, - }) - - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'create', - protected: false, - description: 'Create roles', - }, - { - module: 'moderation', - action: 'update', - protected: false, - description: 'Update moderation', - }, - ]) - - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - id: NEW_ROLE_ID, - name: 'Admin', - }), - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.roleIdRemaps).toHaveProperty( - OLD_ROLE_ID, - NEW_ROLE_ID, - ) - expect(result.remappedManifest).toBeDefined() - expect( - result.remappedManifest?.moderation?.moderationSettings - ?.muteRoleId, - ).toBe(NEW_ROLE_ID) - }) - - test('should delete unmanaged roles when allowProtected is true', async () => { - const desired = createMinimalManifest({ - roles: { - roles: [ - { - id: ROLE_ID_1, - name: 'Keeper', - color: 0, - hoist: false, - mentionable: false, - permissions: '0', - }, - ], - channels: [], - }, - }) - - const actual = createMinimalManifest({ - roles: { - roles: [ - { - id: ROLE_ID_1, - name: 'Keeper', - color: 0, - hoist: false, - mentionable: false, - permissions: '0', - }, - { - id: ROLE_ID_2, - name: 'Obsolete', - color: 0, - hoist: false, - mentionable: false, - permissions: '0', - }, - ], - channels: [], - }, - }) - - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'delete', - protected: true, - description: 'Delete old roles', - }, - ]) - - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => [ - { - id: ROLE_ID_1, - name: 'Keeper', - managed: false, - }, - { - id: ROLE_ID_2, - name: 'Obsolete', - managed: false, - }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - status: 204, - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => [], - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: true, - }) - - expect(result.diagnostics.appliedModules).toContain('roles') - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/roles/${ROLE_ID_2}`), - expect.objectContaining({ - method: 'DELETE', - }), - ) - }) - - test('should not delete managed roles', async () => { - const desired = createMinimalManifest({ - roles: { - roles: [], - channels: [], - }, - }) - - const actual = createMinimalManifest({ - roles: { - roles: [], - channels: [], - }, - }) - - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'delete', - protected: true, - description: 'Clean roles', - }, - ]) - - const MANAGED_ROLE_ID = 'managed-role' - - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => [ - { - id: MANAGED_ROLE_ID, - name: 'Bot', - managed: true, - }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => [], - }) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: true, - }) - - expect(result.diagnostics.appliedModules).toContain('roles') - const deleteCalls = (mockFetch.mock.calls as any[][]).filter( - (call) => call[1]?.method === 'DELETE', - ) - expect(deleteCalls).toHaveLength(0) - }) - - test('should skip parity module with diagnostic', async () => { - const desired = createMinimalManifest({ - parity: { - shadowMode: false, - externalBots: [], - checklist: [], - cutoverReady: true, - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'parity', - action: 'update', - protected: false, - description: 'Update parity', - }, - ]) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.skippedModules).toContain( - 'parity requires checklist/cutover workflow', - ) - }) - - test('should apply onboarding module with prompts containing options', async () => { - const desired = createMinimalManifest({ - onboarding: { - enabled: true, - mode: 0, - defaultChannelIds: [CHANNEL_ID_1], - prompts: [ - { - id: 'prompt-1', - title: 'Pick a channel', - singleSelect: true, - required: false, - inOnboarding: true, - type: 0, - options: [ - { - id: 'opt-1', - title: 'General', - description: null, - channelIds: [CHANNEL_ID_1], - roleIds: [ROLE_ID_1], - emoji: null, - }, - ], - }, - ], - }, - }) - const actual = createMinimalManifest() - const plan = createMinimalPlan([ - { - module: 'onboarding', - action: 'update', - protected: false, - description: 'Update', - }, - ]) - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - expect(result.diagnostics.appliedModules).toContain('onboarding') - const body = JSON.parse( - (mockFetch.mock.calls[0] as any[])[1].body as string, - ) - expect(body.prompts[0].options[0].channel_ids).toEqual([ - CHANNEL_ID_1, - ]) - expect(body.prompts[0].options[0].role_ids).toEqual([ROLE_ID_1]) - }) - - test('should apply command access grants', async () => { - const desired = createMinimalManifest({ - commandaccess: { - grants: [ - { - roleId: ROLE_ID_1, - module: 'music', - mode: 'allow' as any, - }, - ], - }, - }) - const actual = createMinimalManifest() - const plan = createMinimalPlan([ - { - module: 'commandaccess', - action: 'update', - protected: false, - description: 'Update', - }, - ]) - const { guildRoleAccessService } = - await import('@lucky/shared/services') - ;( - guildRoleAccessService.replaceRoleGrants as jest.Mock - ).mockResolvedValue(undefined) - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - expect(result.diagnostics.appliedModules).toContain('commandaccess') - expect( - guildRoleAccessService.replaceRoleGrants as jest.Mock, - ).toHaveBeenCalledWith(GUILD_ID, [ - { roleId: ROLE_ID_1, module: 'music', mode: 'allow' }, - ]) - }) - - test('should update existing channel by ID', async () => { - const desired = createMinimalManifest({ - roles: { - roles: [], - channels: [ - { - id: CHANNEL_ID_1, - name: 'updated-name', - type: 'GuildText', - parentId: null, - topic: null, - readonly: false, - }, - ], - }, - }) - const actual = createMinimalManifest({ - roles: { - roles: [], - channels: [ - { - id: CHANNEL_ID_1, - name: 'old-name', - type: 'GuildText', - parentId: null, - topic: null, - readonly: false, - }, - ], - }, - }) - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'update', - protected: false, - description: 'Update channel', - }, - ]) - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - expect(result.diagnostics.appliedModules).toContain('roles') - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/channels/${CHANNEL_ID_1}`), - expect.objectContaining({ method: 'PATCH' }), - ) - }) - - test('should delete stale channels when allowProtected is true', async () => { - const desired = createMinimalManifest({ - roles: { - roles: [], - channels: [ - { - id: CHANNEL_ID_1, - name: 'keep', - type: 'GuildText', - parentId: null, - topic: null, - readonly: false, - }, - ], - }, - }) - const actual = createMinimalManifest({ - roles: { roles: [], channels: [] }, - }) - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'delete', - protected: true, - description: 'Delete stale', - }, - ]) - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ id: CHANNEL_ID_1, name: 'keep' }), - }) // POST create channel - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => [], - }) // GET roles for prune - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => [ - // GET channels for prune - { id: CHANNEL_ID_2, name: 'stale' }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - status: 204, - json: async () => undefined, - }) // DELETE stale channel - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: true, - }) - expect(result.diagnostics.appliedModules).toContain('roles') - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/channels/${CHANNEL_ID_2}`), - expect.objectContaining({ method: 'DELETE' }), - ) - }) - - test('should apply remapping through all manifest sections when roles/channels are remapped', async () => { - const OLD_ROLE = 'old-role-111' - const NEW_ROLE = 'new-role-222' - const OLD_CHANNEL = 'old-ch-111' - const NEW_CHANNEL = 'new-ch-222' - - const desired = createMinimalManifest({ - roles: { - roles: [ - { - id: OLD_ROLE, - name: 'Member', - color: 0, - hoist: false, - mentionable: false, - }, - ], - channels: [ - { - id: OLD_CHANNEL, - name: 'general', - type: 'GuildText', - parentId: null, - topic: null, - readonly: false, - }, - ], - }, - onboarding: { - enabled: true, - mode: 0, - defaultChannelIds: [OLD_CHANNEL], - prompts: [], - }, - moderation: { - automod: { - exemptRoles: [OLD_ROLE], - exemptChannels: [OLD_CHANNEL], - } as any, - moderationSettings: { - muteRoleId: OLD_ROLE, - modRoleIds: [OLD_ROLE], - adminRoleIds: [], - } as any, - }, - automessages: { - welcome: { - enabled: true, - channelId: OLD_CHANNEL, - message: 'hi', - }, - leave: { - enabled: true, - channelId: OLD_CHANNEL, - message: 'bye', - }, - }, - reactionroles: { - messages: [ - { - id: 'm1', - messageId: 'dm1', - channelId: OLD_CHANNEL, - mappings: [ - { - roleId: OLD_ROLE, - label: 'x', - emoji: undefined, - style: undefined, - }, - ], - }, - ], - exclusiveRoles: [ - { roleId: OLD_ROLE, excludedRoleId: OLD_ROLE }, - ], - }, - commandaccess: { - grants: [ - { - roleId: OLD_ROLE, - module: 'music', - mode: 'allow' as any, - }, - ], - }, - }) - const actual = createMinimalManifest({ - roles: { roles: [], channels: [] }, - }) - const plan = createMinimalPlan([ - { - module: 'roles', - action: 'create', - protected: false, - description: 'Create', - }, - ]) - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ id: NEW_ROLE, name: 'Member' }), - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ id: NEW_CHANNEL, name: 'general' }), - }) - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - expect(result.remappedManifest).toBeDefined() - expect(result.diagnostics.roleIdRemaps).toEqual({ - [OLD_ROLE]: NEW_ROLE, - }) - expect(result.diagnostics.channelIdRemaps).toEqual({ - [OLD_CHANNEL]: NEW_CHANNEL, - }) - }) - - test('should note when reactionroles messages require manual setup', async () => { - const desired = createMinimalManifest({ - reactionroles: { - messages: [ - { - id: 'msg1', - messageId: 'discord-msg-1', - channelId: CHANNEL_ID_1, - mappings: [], - }, - ], - exclusiveRoles: [], - }, - }) - - const actual = createMinimalManifest() - - const plan = createMinimalPlan([ - { - module: 'reactionroles', - action: 'create', - protected: false, - description: 'Create reaction roles', - }, - ]) - - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.executeApplyPlan({ - guildId: GUILD_ID, - plan, - desired, - actual, - allowProtected: false, - }) - - expect(result.diagnostics.skippedModules).toContain( - 'reactionroles.messages requires manual message-template publish', - ) - }) - }) - - describe('captureGuildAutomationState - reaction roles and exclusive roles coverage', () => { - test('should capture reaction role messages with complete mappings', async () => { - const mockGuild = { id: GUILD_ID, name: 'Test Guild' } - const mockRoles = [ - { id: GUILD_ID, name: '@everyone', color: 0 }, - { id: ROLE_ID_1, name: 'Member', color: 3066993 }, - { id: ROLE_ID_2, name: 'Moderator', color: 15158332 }, - ] - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => mockRoles, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not Found', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([ - { - id: 'rrm1', - messageId: 'msg-discord-1', - channelId: CHANNEL_ID_1, - mappings: [ - { - roleId: ROLE_ID_1, - label: 'Member Role', - emoji: '👤', - style: 'primary', - }, - { - roleId: ROLE_ID_2, - label: 'Moderator Role', - emoji: '👮', - style: 'danger', - }, - ], - }, - ]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([ - { - roleId: ROLE_ID_1, - excludedRoleId: ROLE_ID_2, - }, - ]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.reactionroles?.messages).toHaveLength(1) - expect(result.reactionroles?.messages[0].id).toBe('rrm1') - expect(result.reactionroles?.messages[0].mappings).toHaveLength(2) - expect(result.reactionroles?.messages[0].mappings[0]).toEqual({ - roleId: ROLE_ID_1, - label: 'Member Role', - emoji: '👤', - style: 'primary', - }) - expect(result.reactionroles?.exclusiveRoles).toHaveLength(1) - expect(result.reactionroles?.exclusiveRoles[0]).toEqual({ - roleId: ROLE_ID_1, - excludedRoleId: ROLE_ID_2, - }) - }) - - test('should handle reaction role mappings with missing optional fields', async () => { - const mockGuild = { id: GUILD_ID, name: 'Test Guild' } - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [ - { id: GUILD_ID, name: '@everyone' }, - { id: ROLE_ID_1, name: 'Member' }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not Found', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([ - { - id: 'rrm2', - messageId: 'msg-2', - channelId: CHANNEL_ID_1, - mappings: [ - { - roleId: ROLE_ID_1, - }, - ], - }, - ]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.reactionroles?.messages[0].mappings[0]).toEqual({ - roleId: ROLE_ID_1, - label: ROLE_ID_1, - emoji: undefined, - style: undefined, - }) - }) - - test('should capture command access grants', async () => { - const mockGuild = { id: GUILD_ID, name: 'Test Guild' } - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [ - { id: GUILD_ID, name: '@everyone' }, - { id: ROLE_ID_1, name: 'Admin' }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not Found', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue(null) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue(null) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([ - { - roleId: ROLE_ID_1, - module: 'music', - mode: 'view', - }, - { - roleId: ROLE_ID_1, - module: 'settings', - mode: 'manage', - }, - ]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.commandaccess?.grants).toHaveLength(2) - expect(result.commandaccess?.grants[0]).toEqual({ - roleId: ROLE_ID_1, - module: 'music', - mode: 'view', - }) - expect(result.commandaccess?.grants[1]).toEqual({ - roleId: ROLE_ID_1, - module: 'settings', - mode: 'manage', - }) - }) - - test('should handle automessages with null values', async () => { - const mockGuild = { id: GUILD_ID, name: 'Test Guild' } - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockGuild, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [{ id: GUILD_ID, name: '@everyone' }], - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [], - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not Found', - }) - ;( - guildAutomationService.getManifest as jest.Mock - ).mockResolvedValue(null) - ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) - ;(getModerationSettings as jest.Mock).mockResolvedValue(null) - ;( - autoMessageService.getWelcomeMessage as jest.Mock - ).mockResolvedValue({ - enabled: true, - channelId: null, - message: null, - }) - ;( - autoMessageService.getLeaveMessage as jest.Mock - ).mockResolvedValue({ - enabled: false, - channelId: CHANNEL_ID_1, - message: 'Goodbye!', - }) - ;( - reactionRolesService.listReactionRoleMessages as jest.Mock - ).mockResolvedValue([]) - ;( - roleManagementService.listExclusiveRoles as jest.Mock - ).mockResolvedValue([]) - ;( - guildRoleAccessService.listRoleGrants as jest.Mock - ).mockResolvedValue([]) - - const result = - await guildAutomationExecutionService.captureGuildAutomationState( - GUILD_ID, - ) - - expect(result.automessages?.welcome).toEqual({ - enabled: true, - channelId: undefined, - message: undefined, - }) - expect(result.automessages?.leave).toEqual({ - enabled: false, - channelId: CHANNEL_ID_1, - message: 'Goodbye!', - }) - }) - }) - - describe('GuildAutomationExecutionError', () => { - it('should create error with default status code', () => { - const error = new GuildAutomationExecutionError('Test error') - expect(error.message).toBe('Test error') - expect(error.statusCode).toBe(500) - expect(error.name).toBe('GuildAutomationExecutionError') - }) - - it('should create error with custom status code', () => { - const error = new GuildAutomationExecutionError('Not found', 404) - expect(error.statusCode).toBe(404) - }) - }) - - describe('utility functions', () => { - it('mapChannelType should convert Discord channel types to type names', () => { - expect(mapChannelType(0)).toBe('GuildText') - expect(mapChannelType(2)).toBe('GuildVoice') - expect(mapChannelType(4)).toBe('GuildCategory') - expect(mapChannelType(5)).toBe('GuildAnnouncement') - expect(mapChannelType(13)).toBe('GuildStageVoice') - expect(mapChannelType(15)).toBe('GuildForum') - expect(mapChannelType(999)).toBe('GuildText') - }) - - it('toDiscordChannelType should convert type names to Discord channel types', () => { - expect(toDiscordChannelType('GuildText')).toBe(0) - expect(toDiscordChannelType('GuildVoice')).toBe(2) - expect(toDiscordChannelType('GuildCategory')).toBe(4) - expect(toDiscordChannelType('GuildAnnouncement')).toBe(5) - expect(toDiscordChannelType('GuildStageVoice')).toBe(13) - expect(toDiscordChannelType('GuildForum')).toBe(15) - expect(toDiscordChannelType('Unknown')).toBe(0) - }) - - it('isExpectedDeleteError should identify forbidden and not found errors', () => { - const err403 = new GuildAutomationExecutionError('Forbidden', 403) - const err404 = new GuildAutomationExecutionError('Not found', 404) - const err500 = new GuildAutomationExecutionError( - 'Server error', - 500, - ) - const nonError = new Error('Regular error') - - expect(isExpectedDeleteError(err403)).toBe(true) - expect(isExpectedDeleteError(err404)).toBe(true) - expect(isExpectedDeleteError(err500)).toBe(false) - expect(isExpectedDeleteError(nonError)).toBe(false) - expect(isExpectedDeleteError(null)).toBe(false) - }) - - it('isOnboardingUnavailable should identify forbidden and not found errors', () => { - const err403 = new GuildAutomationExecutionError('Forbidden', 403) - const err404 = new GuildAutomationExecutionError('Not found', 404) - const err400 = new GuildAutomationExecutionError('Bad request', 400) - - expect(isOnboardingUnavailable(err403)).toBe(true) - expect(isOnboardingUnavailable(err404)).toBe(true) - expect(isOnboardingUnavailable(err400)).toBe(false) - }) - - it('normalizeName should trim and normalize strings', () => { - expect(normalizeName(' Hello ')).toBe('hello') - expect(normalizeName('Test Name')).toBe('test name') - expect(normalizeName('UPPER')).toBe('upper') - }) - - it('asObject should convert values to objects', () => { - expect(asObject({})).toEqual({}) - expect(asObject({ key: 'value' })).toEqual({ key: 'value' }) - expect(asObject(null)).toBeNull() - expect(asObject([])).toBeNull() - expect(asObject('string')).toBeNull() - expect(asObject(123)).toBeNull() - }) - - it('toAutoModPayload should convert to payload or null', () => { - expect(toAutoModPayload({ test: true })).toEqual({ test: true }) - expect(toAutoModPayload(null)).toBeNull() - expect(toAutoModPayload('string')).toBeNull() - expect(toAutoModPayload([])).toBeNull() - }) - - it('toModerationPayload should convert to payload or null', () => { - expect(toModerationPayload({ test: true })).toEqual({ test: true }) - expect(toModerationPayload(null)).toBeNull() - expect(toModerationPayload([])).toBeNull() - }) - }) -}) diff --git a/packages/bot/src/functions/management/commands/guildconfig.ts b/packages/bot/src/functions/management/commands/guildconfig.ts index c8d48e8ae..d947677c0 100644 --- a/packages/bot/src/functions/management/commands/guildconfig.ts +++ b/packages/bot/src/functions/management/commands/guildconfig.ts @@ -10,7 +10,8 @@ import { guildAutomationService } from '@lucky/shared/services' import { interactionReply } from '../../../utils/general/interactionReply' import { captureGuildAutomationState } from '../../../utils/guildAutomation/captureGuildState' import { applyAutomationModules } from '../../../utils/guildAutomation/applyPlan' -import { errorLog } from '@lucky/shared/utils' +import { errorLog, infoLog } from '@lucky/shared/utils' +import { guildAutomationUsageTotal } from '../../../utils/monitoring/prometheus' function summaryEmbed(params: { title: string @@ -111,7 +112,10 @@ export default new Command({ if (!interaction.guild) { await interactionReply({ interaction, - content: { content: '❌ This command can only be used in a server.', ephemeral: true }, + content: { + content: '❌ This command can only be used in a server.', + ephemeral: true, + }, }) return } @@ -158,6 +162,7 @@ export default new Command({ } if (subcommand === 'plan') { + guildAutomationUsageTotal.inc({ operation: 'plan' }) const current = await captureGuildAutomationState( guild, interaction.client.user?.id, @@ -188,7 +193,8 @@ export default new Command({ { name: 'Protected Ops', value: String( - result.plan.protectedOperations.length, + result.plan.protectedOperations + .length, ), inline: true, }, @@ -201,6 +207,7 @@ export default new Command({ } if (subcommand === 'apply' || subcommand === 'reconcile') { + guildAutomationUsageTotal.inc({ operation: subcommand }) const allowProtected = interaction.options.getBoolean('allow_protected') ?? false const current = await captureGuildAutomationState( @@ -253,6 +260,15 @@ export default new Command({ }) } + infoLog({ + message: `Guild Automation ${subcommand} executed`, + data: { + guildId: guild.id, + subcommand, + blockedByProtected, + }, + }) + await interactionReply({ interaction, content: { @@ -278,7 +294,9 @@ export default new Command({ inline: true, }, ], - color: blockedByProtected ? 0xf59e0b : COLOR.INFO_GREEN, + color: blockedByProtected + ? 0xf59e0b + : COLOR.INFO_GREEN, }), ], }, diff --git a/packages/bot/src/utils/monitoring/prometheus.ts b/packages/bot/src/utils/monitoring/prometheus.ts index 2d865c27a..9ef2fc9a5 100644 --- a/packages/bot/src/utils/monitoring/prometheus.ts +++ b/packages/bot/src/utils/monitoring/prometheus.ts @@ -1,6 +1,7 @@ import { Registry, collectDefaultMetrics, + Counter, Gauge, type CollectFunction, } from 'prom-client' @@ -47,6 +48,21 @@ export const guildsGauge = new Gauge<'state'>({ collect: guildsGaugeCollect, }) +/** + * Counter: Guild Automation usage from the Discord `/guildconfig` command, + * labelled by operation type (plan|apply|reconcile). Mirrors the backend's + * `lucky_guild_automation_usage_total` so the migration-freeze demand signal + * captures BOTH surfaces (web/API + Discord command), not just the web. The + * two counters are summed at decision time. Cardinality is bounded by the + * operation label; guild id stays in logs, not labels. + */ +export const guildAutomationUsageTotal = new Counter<'operation'>({ + name: 'lucky_guild_automation_usage_total', + help: 'Count of Guild Automation plan/apply/reconcile attempts via the Discord /guildconfig command, labelled by operation type.', + labelNames: ['operation'], + registers: [registry], +}) + /** * Render the registry as Prometheus text exposition format. */ diff --git a/packages/frontend/src/components/Features/GlobalTogglesSection.test.tsx b/packages/frontend/src/components/Features/GlobalTogglesSection.test.tsx index 5b25112be..c43b6543a 100644 --- a/packages/frontend/src/components/Features/GlobalTogglesSection.test.tsx +++ b/packages/frontend/src/components/Features/GlobalTogglesSection.test.tsx @@ -7,7 +7,12 @@ import type { FeatureToggleName, FeatureToggleState } from '@/types' vi.mock('@/stores/featuresStore') vi.mock('@/components/Features/FeatureCard', () => ({ - default: ({ feature, enabled, onToggle, readOnly }: { + default: ({ + feature, + enabled, + onToggle, + readOnly, + }: { feature: { name: string } enabled: boolean onToggle: (v: boolean) => void @@ -56,7 +61,6 @@ describe('GlobalTogglesSection', () => { }) test.each([ - ['vercel', 'Vercel'], ['database', 'Database'], ['environment', 'Environment'], ] as const)('displays %s provider label as "%s"', (provider, label) => { @@ -87,8 +91,16 @@ describe('GlobalTogglesSection', () => { test('renders a FeatureCard per feature in store', () => { mockStore([ - { name: 'AUTOPLAY' as FeatureToggleName, description: 'A', isGlobal: true }, - { name: 'LYRICS' as FeatureToggleName, description: 'B', isGlobal: true }, + { + name: 'AUTOPLAY' as FeatureToggleName, + description: 'A', + isGlobal: true, + }, + { + name: 'LYRICS' as FeatureToggleName, + description: 'B', + isGlobal: true, + }, ]) render() expect(screen.getByTestId('feature-card-AUTOPLAY')).toBeInTheDocument() @@ -96,38 +108,88 @@ describe('GlobalTogglesSection', () => { }) test('passes correct enabled state from toggles map', () => { - mockStore([{ name: 'AUTOPLAY' as FeatureToggleName, description: 'A', isGlobal: true }]) + mockStore([ + { + name: 'AUTOPLAY' as FeatureToggleName, + description: 'A', + isGlobal: true, + }, + ]) render( , ) - expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute('data-enabled', 'true') + expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute( + 'data-enabled', + 'true', + ) }) test('defaults missing toggle to false', () => { - mockStore([{ name: 'AUTOPLAY' as FeatureToggleName, description: 'A', isGlobal: true }]) - render() - expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute('data-enabled', 'false') + mockStore([ + { + name: 'AUTOPLAY' as FeatureToggleName, + description: 'A', + isGlobal: true, + }, + ]) + render( + , + ) + expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute( + 'data-enabled', + 'false', + ) }) test('passes readOnly=true when writable=false', () => { - mockStore([{ name: 'AUTOPLAY' as FeatureToggleName, description: 'A', isGlobal: true }]) + mockStore([ + { + name: 'AUTOPLAY' as FeatureToggleName, + description: 'A', + isGlobal: true, + }, + ]) render() - expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute('data-readonly', 'true') + expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute( + 'data-readonly', + 'true', + ) }) test('passes readOnly=false when writable=true', () => { - mockStore([{ name: 'AUTOPLAY' as FeatureToggleName, description: 'A', isGlobal: true }]) + mockStore([ + { + name: 'AUTOPLAY' as FeatureToggleName, + description: 'A', + isGlobal: true, + }, + ]) render() - expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute('data-readonly', 'false') + expect(screen.getByTestId('feature-card-AUTOPLAY')).toHaveAttribute( + 'data-readonly', + 'false', + ) }) test('fires onToggle with feature name and new state when card clicked', async () => { const user = userEvent.setup() - const onToggle = vi.fn() as (name: FeatureToggleName, enabled: boolean) => void - mockStore([{ name: 'AUTOPLAY' as FeatureToggleName, description: 'A', isGlobal: true }]) + const onToggle = vi.fn() as ( + name: FeatureToggleName, + enabled: boolean, + ) => void + mockStore([ + { + name: 'AUTOPLAY' as FeatureToggleName, + description: 'A', + isGlobal: true, + }, + ]) render( = { - vercel: 'Vercel', database: 'Database', environment: 'Environment', } diff --git a/packages/frontend/src/hooks/index.ts b/packages/frontend/src/hooks/index.ts index fbad56f49..761504c25 100644 --- a/packages/frontend/src/hooks/index.ts +++ b/packages/frontend/src/hooks/index.ts @@ -6,5 +6,3 @@ export * from './useGuildSelection' export * from './useServerFilter' export * from './usePageMetadata' export * from './useModerationQueries' -export * from './useAutoModQueries' -export * from './useLogsQueries' diff --git a/packages/frontend/src/hooks/useAutoModQueries.ts b/packages/frontend/src/hooks/useAutoModQueries.ts deleted file mode 100644 index 45a90f12e..000000000 --- a/packages/frontend/src/hooks/useAutoModQueries.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { api } from '@/services/api' -import type { AutoModSettings } from '@/types' - -export function useAutoModSettings(guildId: string | undefined) { - return useQuery({ - queryKey: ['automod', 'settings', guildId], - queryFn: async () => { - if (!guildId) throw new Error('Guild ID is required') - const response = await api.automod.getSettings(guildId) - return response.data.settings as AutoModSettings - }, - enabled: !!guildId, - }) -} - -export function useUpdateAutoModSettings() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ - guildId, - settings, - }: { - guildId: string - settings: Partial - }) => { - const response = await api.automod.updateSettings(guildId, settings) - return response.data.settings - }, - onSuccess: (_, { guildId }) => { - queryClient.invalidateQueries({ - queryKey: ['automod', 'settings', guildId], - }) - }, - }) -} - -export function useAddAutoModWord() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ - guildId, - word, - }: { - guildId: string - word: string - }) => { - await api.automod.addWord(guildId, word) - }, - onSuccess: (_, { guildId }) => { - queryClient.invalidateQueries({ - queryKey: ['automod', 'settings', guildId], - }) - }, - }) -} - -export function useRemoveAutoModWord() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ - guildId, - word, - }: { - guildId: string - word: string - }) => { - await api.automod.removeWord(guildId, word) - }, - onSuccess: (_, { guildId }) => { - queryClient.invalidateQueries({ - queryKey: ['automod', 'settings', guildId], - }) - }, - }) -} diff --git a/packages/frontend/src/hooks/useCountUp.test.ts b/packages/frontend/src/hooks/useCountUp.test.ts deleted file mode 100644 index 22f1598a4..000000000 --- a/packages/frontend/src/hooks/useCountUp.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' -import { act, renderHook } from '@testing-library/react' -import { useCountUp } from './useCountUp' - -describe('useCountUp', () => { - const originalRequestAnimationFrame = globalThis.requestAnimationFrame - const originalCancelAnimationFrame = globalThis.cancelAnimationFrame - - beforeEach(() => { - vi.useFakeTimers() - - globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { - const timeoutId = setTimeout(() => callback(Date.now()), 16) - - return timeoutId as unknown as number - }) as typeof requestAnimationFrame - - globalThis.cancelAnimationFrame = ((animationFrameId: number) => { - clearTimeout(animationFrameId as unknown as ReturnType) - }) as typeof cancelAnimationFrame - }) - - afterEach(() => { - vi.clearAllTimers() - vi.useRealTimers() - - globalThis.requestAnimationFrame = originalRequestAnimationFrame - globalThis.cancelAnimationFrame = originalCancelAnimationFrame - }) - - const advanceTimers = async (ms: number) => { - await act(async () => { - vi.advanceTimersByTime(ms) - }) - } - - test('should initialize with value 0', () => { - const { result } = renderHook(() => useCountUp(100)) - - expect(result.current.value).toBe(0) - expect(result.current.isComplete).toBe(false) - }) - - test('should delay the animation start once', async () => { - const { result } = renderHook(() => - useCountUp(100, { duration: 100, delay: 50 }), - ) - - expect(result.current.value).toBe(0) - expect(result.current.isComplete).toBe(false) - - await advanceTimers(50) - expect(result.current.value).toBe(0) - - await advanceTimers(16) - expect(result.current.value).toBeGreaterThan(0) - expect(result.current.isComplete).toBe(false) - }) - - test('should complete the animation and set isComplete', async () => { - const { result } = renderHook(() => - useCountUp(100, { duration: 100 }), - ) - - await advanceTimers(200) - - expect(result.current.value).toBe(100) - expect(result.current.isComplete).toBe(true) - }) - - test('should reset state when the target changes', async () => { - const { result, rerender } = renderHook( - ({ targetValue }) => - useCountUp(targetValue, { duration: 100 }), - { - initialProps: { targetValue: 100 }, - }, - ) - - await advanceTimers(32) - - expect(result.current.value).toBeGreaterThan(0) - expect(result.current.value).toBeLessThan(100) - - rerender({ targetValue: 200 }) - - expect(result.current.value).toBe(0) - expect(result.current.isComplete).toBe(false) - - await advanceTimers(200) - - expect(result.current.value).toBe(200) - expect(result.current.isComplete).toBe(true) - }) -}) diff --git a/packages/frontend/src/hooks/useCountUp.ts b/packages/frontend/src/hooks/useCountUp.ts deleted file mode 100644 index a992d3863..000000000 --- a/packages/frontend/src/hooks/useCountUp.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { useState, useEffect } from 'react' - -interface UseCountUpOptions { - duration?: number - delay?: number -} - -/** - * Hook to animate a count from 0 to a target value - * Uses requestAnimationFrame for smooth animation - */ -export function useCountUp( - targetValue: number, - { duration = 1000, delay = 0 }: UseCountUpOptions = {}, -) { - const [value, setValue] = useState(0) - const [isComplete, setIsComplete] = useState(false) - - useEffect(() => { - let animationFrameId: number | undefined - let timeoutId: ReturnType | undefined - let cancelled = false - - setValue(0) - setIsComplete(false) - - const animate = () => { - if (cancelled) { - return - } - - const startTime = Date.now() - - const step = () => { - if (cancelled) { - return - } - - const now = Date.now() - - const elapsed = now - startTime - const progress = Math.min(elapsed / duration, 1) - - // Easing function: ease-out cubic - const easeProgress = - 1 - Math.pow(1 - progress, 3) - - const currentValue = Math.floor( - easeProgress * targetValue, - ) - - setValue(currentValue) - - if (progress < 1) { - animationFrameId = requestAnimationFrame(step) - } else { - setValue(targetValue) - setIsComplete(true) - } - } - - animationFrameId = requestAnimationFrame(step) - } - - if (delay > 0) { - timeoutId = setTimeout(animate, delay) - } else { - animate() - } - - return () => { - cancelled = true - - if (animationFrameId !== undefined) { - cancelAnimationFrame(animationFrameId) - } - if (timeoutId !== undefined) { - clearTimeout(timeoutId) - } - } - }, [targetValue, duration, delay]) - - return { value, isComplete } -} diff --git a/packages/frontend/src/hooks/useFeatures.test.ts b/packages/frontend/src/hooks/useFeatures.test.ts index 1f0a0be04..35c745b29 100644 --- a/packages/frontend/src/hooks/useFeatures.test.ts +++ b/packages/frontend/src/hooks/useFeatures.test.ts @@ -16,7 +16,7 @@ type AuthState = { type FeaturesState = { globalToggles: Record - globalToggleProvider: 'vercel' | 'environment' + globalToggleProvider: 'database' | 'environment' globalTogglesWritable: boolean isLoading: boolean loadError: { @@ -54,7 +54,7 @@ describe('useFeatures', () => { } featuresState = { globalToggles: { DOWNLOAD_VIDEO: true }, - globalToggleProvider: 'vercel', + globalToggleProvider: 'database', globalTogglesWritable: false, isLoading: false, loadError: null, diff --git a/packages/frontend/src/hooks/useLogsQueries.ts b/packages/frontend/src/hooks/useLogsQueries.ts deleted file mode 100644 index 8bc01573a..000000000 --- a/packages/frontend/src/hooks/useLogsQueries.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { api } from '@/services/api' - -export function useRecentLogs(guildId: string | undefined, limit?: number) { - return useQuery({ - queryKey: ['logs', 'recent', guildId, limit], - queryFn: async () => { - if (!guildId) throw new Error('Guild ID is required') - const response = await api.serverLogs.getRecent(guildId, limit) - return response.data.logs - }, - enabled: !!guildId, - }) -} - -export function useLogsByType( - guildId: string | undefined, - type: string, - limit?: number, -) { - return useQuery({ - queryKey: ['logs', 'type', guildId, type, limit], - queryFn: async () => { - if (!guildId) throw new Error('Guild ID is required') - const response = await api.serverLogs.getByType( - guildId, - type, - limit, - ) - return response.data.logs - }, - enabled: !!guildId && !!type, - }) -} - -export function useLogStats(guildId: string | undefined) { - return useQuery({ - queryKey: ['logs', 'stats', guildId], - queryFn: async () => { - if (!guildId) throw new Error('Guild ID is required') - const response = await api.serverLogs.getStats(guildId) - return response.data - }, - enabled: !!guildId, - }) -} diff --git a/packages/frontend/src/pages/GuildAutomation.test.tsx b/packages/frontend/src/pages/GuildAutomation.test.tsx index ff25fbe5a..58a46693e 100644 --- a/packages/frontend/src/pages/GuildAutomation.test.tsx +++ b/packages/frontend/src/pages/GuildAutomation.test.tsx @@ -199,7 +199,7 @@ describe('GuildAutomation', () => { await waitFor(() => { expect(screen.getByText('Plan')).toBeInTheDocument() - expect(screen.getByText('Apply')).toBeInTheDocument() + expect(screen.getByText('Record Plan')).toBeInTheDocument() expect(screen.getByText('Reconcile')).toBeInTheDocument() }) }) @@ -278,14 +278,16 @@ describe('GuildAutomation', () => { , ) - await waitFor(() => screen.getByText('Apply')) + await waitFor(() => screen.getByText('Record Plan')) - const applyButton = screen.getByText('Apply') + const applyButton = screen.getByText('Record Plan') await user.click(applyButton) await waitFor(() => { expect(api.automation.apply).toHaveBeenCalledWith('456') - expect(toast.success).toHaveBeenCalledWith('Changes applied.') + expect(toast.success).toHaveBeenCalledWith( + 'Plan recorded. Apply changes using /guildconfig apply in Discord.', + ) }) }) @@ -305,13 +307,13 @@ describe('GuildAutomation', () => { , ) - await waitFor(() => screen.getByText('Apply')) + await waitFor(() => screen.getByText('Record Plan')) - const applyButton = screen.getByText('Apply') + const applyButton = screen.getByText('Record Plan') await user.click(applyButton) await waitFor(() => { - expect(screen.getByText('Apply Result')).toBeInTheDocument() + expect(screen.getByText('Plan Record')).toBeInTheDocument() expect(screen.getByText('2 applied')).toBeInTheDocument() expect( screen.getByText('Applied 2 changes successfully'), @@ -343,7 +345,7 @@ describe('GuildAutomation', () => { await waitFor(() => { expect(api.automation.reconcile).toHaveBeenCalledWith('456') expect(toast.success).toHaveBeenCalledWith( - 'Reconciliation complete.', + 'Drift reconciliation recorded. Apply changes using /guildconfig reconcile in Discord.', ) }) }) @@ -682,7 +684,7 @@ describe('GuildAutomation', () => { const planButton = screen.getByText('Plan') await user.click(planButton) - const applyButton = screen.getByText('Apply') + const applyButton = screen.getByText('Record Plan') const reconcileButton = screen.getByText('Reconcile') expect(planButton).toBeDisabled() diff --git a/packages/frontend/src/pages/GuildAutomation.tsx b/packages/frontend/src/pages/GuildAutomation.tsx index 3124c570d..1a36a535e 100644 --- a/packages/frontend/src/pages/GuildAutomation.tsx +++ b/packages/frontend/src/pages/GuildAutomation.tsx @@ -298,10 +298,12 @@ export default function GuildAutomation() { try { const result = await api.automation.apply(selectedGuild.id) setApplyResult(result) - toast.success('Changes applied.') + toast.success( + 'Plan recorded. Apply changes using /guildconfig apply in Discord.', + ) await fetchData() } catch { - toast.error('Failed to apply changes.') + toast.error('Failed to record plan.') } finally { setActionLoading(null) } @@ -315,10 +317,12 @@ export default function GuildAutomation() { try { const result = await api.automation.reconcile(selectedGuild.id) setApplyResult(result) - toast.success('Reconciliation complete.') + toast.success( + 'Drift reconciliation recorded. Apply changes using /guildconfig reconcile in Discord.', + ) await fetchData() } catch { - toast.error('Failed to reconcile.') + toast.error('Failed to record reconciliation.') } finally { setActionLoading(null) } @@ -415,7 +419,7 @@ export default function GuildAutomation() { ) : ( )} - Apply + Record Plan