Task #138: Wiki.js subscriber tier sync via GraphQL (REQ-2026-04-15-subscriber-wiki-auth)

- src/services/wikijsSync.js: GraphQL client for Wiki.js at subscribers.firefrostgaming.com
  - syncWikiUser(discordId, username, tierLevel): creates user if not exists, updates group if exists
  - demoteToAwakened(discordId): downgrades group on cancellation/chargeback/refund
  - Email convention: {discordId}@firefrost.local (unique internal addresses)
  - Tier → group mapping: 1=Awakened, 2=Elemental, 3=Knight, 4=Master, 5=Legend, 6=Sovereign
  - Silent-fail: never breaks the Stripe webhook
- src/routes/stripe.js: hooked into 4 lifecycle paths:
  - checkout.session.completed → syncWikiUser (non-blocking)
  - customer.subscription.deleted → demoteToAwakened
  - charge.dispute.created (chargeback) → demoteToAwakened
  - charge.refunded (refund ban) → demoteToAwakened
- .env.example: added WIKIJS_URL, WIKIJS_API_KEY, DISCORD_ISSUE_WEBHOOK_URL

PRE-REQ: Michael must create 6 groups in Wiki.js admin + generate API key before deploy.
This commit is contained in:
Claude Code
2026-04-16 00:15:47 -05:00
parent af297f2cda
commit 328e9ba613
6 changed files with 199 additions and 0 deletions

View File

@@ -28,3 +28,12 @@ MINECRAFT_NEST_IDS=1,6,7
STRIPE_SECRET_KEY=sk_test_... # or sk_live_... for production
STRIPE_WEBHOOK_SECRET=whsec_... # Get from Stripe Dashboard webhook settings
BASE_URL=https://discord-bot.firefrostgaming.com # For checkout redirect URLs
# Wiki.js Subscriber Wiki (subscribers.firefrostgaming.com)
# Generate key in Wiki.js Admin → API Access. Groups (Awakened thru Sovereign)
# must be pre-created in Wiki.js admin panel before syncing.
WIKIJS_URL=https://subscribers.firefrostgaming.com
WIKIJS_API_KEY=
# Discord Webhooks (optional — silent-skip if unset)
DISCORD_ISSUE_WEBHOOK_URL=

View File

@@ -15,6 +15,7 @@ const { syncRole, removeAllRoles, downgradeToAwakened } = require('../services/d
const { welcomeNewMember } = require('../services/awakenedConcierge');
const { syncLuckPermsMeta } = require('../services/luckpermsSync');
const { logAction } = require('../services/discordActionLog');
const { syncWikiUser, demoteToAwakened: demoteWikiToAwakened } = require('../services/wikijsSync');
// CORS configuration for checkout endpoint
const corsOptions = {
@@ -298,6 +299,12 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
}
}).catch(err => console.error('[LPSync] Background sync error:', err.message));
// Sync Wiki.js subscriber group (non-blocking, silent-fail)
// REQ-2026-04-15-subscriber-wiki-auth (Task #138)
syncWikiUser(discordId, req.user?.username || discordId, tierLevel)
.then(r => { if (r.success) console.log(`📚 WikiSync checkout: ${discordId}${r.group}`); })
.catch(err => console.error('[WikiSync] checkout error:', err.message));
// Carl-bot migration: link-reminder DM for Awakened+ subscribers (non-blocking, silent-fail)
// REQ-2026-04-15-reaction-roles (Chronicler #92)
(async () => {
@@ -368,6 +375,10 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
const discordId = subRow.rows[0].discord_id;
const roleResult = await downgradeToAwakened(discordId);
console.log(`⬇️ Subscription ended for ${discordId}, demoted to Awakened: ${roleResult.message}`);
// Demote Wiki.js group to Awakened (non-blocking, silent-fail)
demoteWikiToAwakened(discordId)
.catch(err => console.error('[WikiSync] demote error:', err.message));
await client.query(`
INSERT INTO admin_audit_log (action_type, target_identifier, details)
@@ -462,6 +473,8 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
// Remove all Discord roles
const roleResult = await removeAllRoles(discordId);
console.log(`🚫 Chargeback role removal for ${discordId}: ${roleResult.message}`);
demoteWikiToAwakened(discordId)
.catch(err => console.error('[WikiSync] chargeback demote error:', err.message));
}
await client.query(`
@@ -528,6 +541,8 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
// Remove ALL roles including Awakened (hard ban)
const roleResult = await removeAllRoles(discordId);
console.log(`🚫 Refund ban for ${discordId}: ${roleResult.message}`);
demoteWikiToAwakened(discordId)
.catch(err => console.error('[WikiSync] refund ban demote error:', err.message));
}
await client.query(`

View File

@@ -0,0 +1,175 @@
/**
* Wiki.js Tier Sync — syncs subscriber tier groups in Wiki.js via GraphQL API.
* REQ-2026-04-15-subscriber-wiki-auth (Chronicler #92, Task #138)
*
* Wiki.js is deployed at subscribers.firefrostgaming.com. Auth via API key.
* User email convention: {discordId}@firefrost.local (unique, internal).
*
* Pre-requisites (Michael does once in Wiki.js admin panel):
* 1. Create 6 groups: Awakened, Elemental, Knight, Master, Legend, Sovereign
* 2. Generate API key: Admin → API Access → add key → copy to .env
*
* Silent-fail pattern: never let wiki sync break the Stripe webhook.
*/
const axios = require('axios');
const WIKIJS_URL = process.env.WIKIJS_URL || 'https://subscribers.firefrostgaming.com';
const WIKIJS_API_KEY = process.env.WIKIJS_API_KEY || '';
// tier_level (from Stripe webhook) → Wiki.js group name
const TIER_GROUP_MAP = {
1: 'Awakened',
2: 'Elemental',
3: 'Knight',
4: 'Master',
5: 'Legend',
6: 'Sovereign'
};
/**
* Execute a GraphQL query/mutation against Wiki.js.
*/
async function gql(query, variables) {
if (!WIKIJS_API_KEY) {
console.warn('[WikiSync] WIKIJS_API_KEY not set — skipping');
return null;
}
const resp = await axios.post(
`${WIKIJS_URL}/graphql`,
{ query, variables },
{
headers: {
'Authorization': `Bearer ${WIKIJS_API_KEY}`,
'Content-Type': 'application/json'
},
timeout: 10000
}
);
return resp.data;
}
/**
* Fetch all Wiki.js groups and return a map of name → id.
*/
async function getGroupMap() {
const result = await gql(`
query {
groups {
list { id name }
}
}
`);
const groups = result?.data?.groups?.list || [];
const map = {};
for (const g of groups) {
map[g.name] = g.id;
}
return map;
}
/**
* Search for a Wiki.js user by their internal email ({discordId}@firefrost.local).
* Returns { id, email } or null.
*/
async function findUser(discordId) {
const email = `${discordId}@firefrost.local`;
const result = await gql(`
query ($query: String!) {
users {
search(query: $query) { id email }
}
}
`, { query: email });
const users = result?.data?.users?.search || [];
return users.find(u => u.email === email) || null;
}
/**
* Create a Wiki.js user for the given Discord subscriber.
*/
async function createUser(discordId, username, groupIds) {
const email = `${discordId}@firefrost.local`;
const result = await gql(`
mutation ($email: String!, $name: String!, $groups: [Int!]!) {
users {
create(email: $email, name: $name, passwordRaw: "", providerKey: "discord", groups: $groups) {
responseResult { succeeded message }
}
}
}
`, { email, name: username || discordId, groups: groupIds });
const res = result?.data?.users?.create?.responseResult;
if (res && !res.succeeded) {
throw new Error(`Wiki.js create failed: ${res.message}`);
}
return res;
}
/**
* Update a Wiki.js user's group assignment.
*/
async function updateUserGroups(userId, groupIds) {
const result = await gql(`
mutation ($id: Int!, $groups: [Int!]!) {
users {
update(id: $id, groups: $groups) {
responseResult { succeeded message }
}
}
}
`, { id: userId, groups: groupIds });
const res = result?.data?.users?.update?.responseResult;
if (res && !res.succeeded) {
throw new Error(`Wiki.js update failed: ${res.message}`);
}
return res;
}
/**
* Sync a subscriber's Wiki.js group to match their Stripe tier.
* Creates user if not exists, updates group if they do.
*
* @param {string} discordId
* @param {string} username — Discord display name
* @param {number} tierLevel — 1-6 from Stripe webhook
*/
async function syncWikiUser(discordId, username, tierLevel) {
if (!WIKIJS_API_KEY) {
console.warn('[WikiSync] WIKIJS_API_KEY not set — skipping');
return { success: false, reason: 'no API key' };
}
const groupName = TIER_GROUP_MAP[tierLevel];
if (!groupName) {
console.warn(`[WikiSync] Unknown tier level ${tierLevel} — skipping`);
return { success: false, reason: `unknown tier ${tierLevel}` };
}
const groupMap = await getGroupMap();
const groupId = groupMap[groupName];
if (!groupId) {
console.error(`[WikiSync] Group '${groupName}' not found in Wiki.js — did Michael create it?`);
return { success: false, reason: `group '${groupName}' missing` };
}
const existing = await findUser(discordId);
if (existing) {
await updateUserGroups(existing.id, [groupId]);
console.log(`📚 [WikiSync] Updated ${username} (${discordId}) → ${groupName}`);
} else {
await createUser(discordId, username, [groupId]);
console.log(`📚 [WikiSync] Created ${username} (${discordId}) → ${groupName}`);
}
return { success: true, group: groupName };
}
/**
* Demote a subscriber to Awakened group (on cancellation/grace period).
*/
async function demoteToAwakened(discordId) {
return syncWikiUser(discordId, null, 1);
}
module.exports = { syncWikiUser, demoteToAwakened };