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:
@@ -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=
|
||||
|
||||
@@ -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(`
|
||||
|
||||
175
services/arbiter-3.0/src/services/wikijsSync.js
Normal file
175
services/arbiter-3.0/src/services/wikijsSync.js
Normal 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 };
|
||||
Reference in New Issue
Block a user