diff --git a/README.md b/README.md index e26f7d99..e2a897c4 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,13 @@ All permission nodes follow dot-notation and are managed via LuckPerms. Wildcard | `zander.web.scheduler` | Schedule announcements/messages | | `zander.web.vault` | Access vault management | | `zander.web.bridge` | Manage bridge/integrations | +| `zander.web.voting` | Manage voting sites and reward templates | | `zander.web.punishment.view` | View the global punishments list | | `zander.web.punishments` | View punishments on user profiles | +| `zander.web.punishment.manage` | Issue and lift web-based punishments | +| `zander.web.reports` | View player reports on user profiles | +| `zander.web.events` | Access the events dashboard and management | +| `zander.web.events.review` | Access the event approval queue | | `zander.web.audit` | Run audit commands in Discord | | `zander.web.nicknamecheck` | Run nickname check commands | @@ -34,6 +39,7 @@ All permission nodes follow dot-notation and are managed via LuckPerms. Wildcard | `zander.web.tickets.{slug}` | Access a specific ticket category (dynamic, based on category slug) | | `zander.web.tickets.*` | Access all ticket categories | | `zander.web.ticket.escalate` | Escalate support tickets | +| `zander.web.tickets.manageparticipants` | Add or remove users and groups from any support ticket | ### Forums @@ -47,7 +53,7 @@ All permission nodes follow dot-notation and are managed via LuckPerms. Wildcard | `zander.forums.discussion.archive` | Archive forum discussions | | `zander.forums.category.manage` | Manage forum categories (admin dashboard) | -### Discord Punishments +### Discord | Permission Node | Description | |---|---| @@ -56,6 +62,7 @@ All permission nodes follow dot-notation and are managed via LuckPerms. Wildcard | `zander.discord.punish.ban` | Ban/unban users from the Discord server | | `zander.discord.punish.mute` | Mute/unmute users in Discord | | `zander.discord.punish.history` | View punishment history for users | +| `zander.discord.lpaudit` | Run LuckPerms audit commands in Discord | ### Watch / Creator Content @@ -65,6 +72,38 @@ All permission nodes follow dot-notation and are managed via LuckPerms. Wildcard --- +## Events Calendar + +The Events Calendar is a centralized system for scheduling and managing community events across the network and Discord. + +### Event Lifecycle +Events follow a strict state machine to ensure quality and visibility: +1. **Draft**: The initial creation state. Only visible to staff. +2. **Pending Review**: Submitted for approval. Visible in the Review Queue for administrators. +3. **Approved**: Reviewed and ready for publication. +4. **Published**: Visible to the community on the public calendar. Triggers downstream actions. + +### Features +* **Templates**: Define recurring event structures (e.g., Weekly Survival Night) to quickly generate new drafts. +* **Discord Integration**: Publishing an event can automatically create a Discord Guild Event and post a notification message to configured channels. +* **Audit Logging**: Every transition and update is recorded for administrative oversight. + +--- + +## Support Tickets + +The Support Ticket system provides a unified interface for community assistance, integrated with Discord. + +### Managing Participants +Access to a ticket is granted to the **Ticket Owner** and any **Staff** with appropriate category permissions. Additional users or groups can be added to a ticket: +* **Add User**: Grants an individual registered user access to the ticket on the web and the linked Discord channel. +* **Add Group**: Grants all users with a specific LuckPerms rank access to the ticket. +* **Permission logic**: The ability to add/remove participants is available to the Ticket Owner (for non-appeal tickets), Staff members, existing ticket participants, or any user with the `zander.web.tickets.manageparticipants` permission node. + +Permissions are automatically synchronized to the linked Discord channel's overwrites whenever participants are modified. + +--- + ## Creator Content Integration (Twitch & YouTube) The `/watch` page displays live streams and recent videos from community members who have linked their Twitch or YouTube accounts and hold the `zander.watch.creator` permission node. Content is only surfaced if it contains a CFC marker (e.g. `#cfc` in a stream title/tag or video tag/description). @@ -158,4 +197,3 @@ Run `migration/v1.11.0_v1.12.0.sql` against your database to create the four tab | `creator_watch_settings` | Per-user toggles controlling listing visibility and Discord notification preferences | | `creator_content_items` | Cached CFC-eligible content items fetched by the cron jobs | | `creator_content_notifications` | Deduplication log preventing repeat Discord notifications for the same content | - diff --git a/commands/support.mjs b/commands/support.mjs index e6bf72af..eacce0fe 100644 --- a/commands/support.mjs +++ b/commands/support.mjs @@ -10,8 +10,11 @@ import { ActionRowBuilder, ChannelType, EmbedBuilder, + MessageFlags, } from "discord.js"; import { startTicketFlow } from "../lib/discord/ticketFlow.mjs"; +import { getUserPermissions } from "../controllers/userController.js"; +import { hasPermission as hasLuckPermsPermission } from "../lib/discord/permissions.mjs"; import { addTicketGroupParticipant, addTicketUserParticipant, @@ -188,40 +191,40 @@ export class SupportCommand extends Command { } if (subcommand === "add") { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + const userOption = interaction.options.getUser("user"); const roleOption = interaction.options.getRole("role"); if (!userOption && !roleOption) { - return interaction.reply({ + return interaction.editReply({ content: "Provide a user or role to add to this ticket.", - ephemeral: true, }); } const ticketDetails = await getTicketDetailsByChannel(interaction.channel.id); if (!ticketDetails) { - return interaction.reply({ + return interaction.editReply({ content: "This channel is not linked to a ticket.", - ephemeral: true, }); } const categoryStaffRoles = await getCategoryPermissions(ticketDetails.categoryId); const member = await interaction.guild.members.fetch(interaction.user.id); + const userLPPermissions = await getUserPermissions({ discordId: interaction.user.id }); + const hasPermission = member.permissions.has(PermissionFlagsBits.ManageChannels) || - member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)); + member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)) || + hasLuckPermsPermission(userLPPermissions, "zander.web.tickets.manageparticipants"); if (!hasPermission) { - return interaction.reply({ + return interaction.editReply({ content: "You need support staff permissions to update ticket access.", - ephemeral: true, }); } - await interaction.deferReply({ ephemeral: true }); - const additions = []; const staffUserId = await getUserIdByDiscordId(interaction.user.id); @@ -324,40 +327,40 @@ export class SupportCommand extends Command { } if (subcommand === "remove") { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + const userOption = interaction.options.getUser("user"); const roleOption = interaction.options.getRole("role"); if (!userOption && !roleOption) { - return interaction.reply({ + return interaction.editReply({ content: "Provide a user or role to remove from this ticket.", - ephemeral: true, }); } const ticketDetails = await getTicketDetailsByChannel(interaction.channel.id); if (!ticketDetails) { - return interaction.reply({ + return interaction.editReply({ content: "This channel is not linked to a ticket.", - ephemeral: true, }); } const categoryStaffRoles = await getCategoryPermissions(ticketDetails.categoryId); const member = await interaction.guild.members.fetch(interaction.user.id); + const userLPPermissions = await getUserPermissions({ discordId: interaction.user.id }); + const hasPermission = member.permissions.has(PermissionFlagsBits.ManageChannels) || - member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)); + member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)) || + hasLuckPermsPermission(userLPPermissions, "zander.web.tickets.manageparticipants"); if (!hasPermission) { - return interaction.reply({ + return interaction.editReply({ content: "You need support staff permissions to update ticket access.", - ephemeral: true, }); } - await interaction.deferReply({ ephemeral: true }); - const removals = []; const staffUserId = await getUserIdByDiscordId(interaction.user.id); @@ -434,32 +437,33 @@ export class SupportCommand extends Command { } if (subcommand === "status") { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); const state = interaction.options.getString("state", true); const ticketDetails = await getTicketDetailsByChannel(interaction.channel.id); if (!ticketDetails) { - return interaction.reply({ + return interaction.editReply({ content: "This channel is not linked to a ticket.", - ephemeral: true, }); } const categoryStaffRoles = await getCategoryPermissions(ticketDetails.categoryId); const member = await interaction.guild.members.fetch(interaction.user.id); + const userLPPermissions = await getUserPermissions({ discordId: interaction.user.id }); + const hasPermission = member.permissions.has(PermissionFlagsBits.ManageChannels) || - member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)); + member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)) || + hasLuckPermsPermission(userLPPermissions, "zander.web.tickets.manageparticipants") || + hasLuckPermsPermission(userLPPermissions, "zander.web.tickets"); if (!hasPermission) { - return interaction.reply({ + return interaction.editReply({ content: "You need support staff permissions to update ticket status.", - ephemeral: true, }); } - await interaction.deferReply({ ephemeral: true }); - const username = interaction.user.tag; const staffUserId = await getUserIdByDiscordId(interaction.user.id); const statusMessages = { @@ -517,31 +521,32 @@ export class SupportCommand extends Command { } if (subcommand === "close") { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + const ticketDetails = await getTicketDetailsByChannel(interaction.channel.id); if (!ticketDetails) { - return interaction.reply({ + return interaction.editReply({ content: "This channel is not linked to a ticket.", - ephemeral: true, }); } const categoryStaffRoles = await getCategoryPermissions(ticketDetails.categoryId); const member = await interaction.guild.members.fetch(interaction.user.id); + const userLPPermissions = await getUserPermissions({ discordId: interaction.user.id }); const isStaff = member.permissions.has(PermissionFlagsBits.ManageChannels) || - member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)); + member.roles.cache.some((role) => categoryStaffRoles.includes(role.id)) || + hasLuckPermsPermission(userLPPermissions, "zander.web.tickets.manageparticipants") || + hasLuckPermsPermission(userLPPermissions, "zander.web.tickets"); const isOwner = ticketDetails.discordId && ticketDetails.discordId === interaction.user.id; if (!isStaff && !isOwner) { - return interaction.reply({ + return interaction.editReply({ content: "Only ticket staff or the ticket owner can close this ticket.", - ephemeral: true, }); } - await interaction.deferReply({ ephemeral: true }); - const username = interaction.user.tag; const actorUserId = await getUserIdByDiscordId(interaction.user.id); @@ -583,10 +588,16 @@ export class SupportCommand extends Command { } if (subcommand === "manual") { - if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageChannels)) { - return interaction.reply({ - content: "You need Manage Channels permission to create a manual ticket.", - ephemeral: true, + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + const userLPPermissions = await getUserPermissions({ discordId: interaction.user.id }); + const hasManualPermission = + interaction.memberPermissions.has(PermissionFlagsBits.ManageChannels) || + hasLuckPermsPermission(userLPPermissions, "zander.web.tickets"); + + if (!hasManualPermission) { + return interaction.editReply({ + content: "You need Manage Channels or support staff permissions to create a manual ticket.", }); } @@ -611,14 +622,11 @@ export class SupportCommand extends Command { } if (!ownerUserId) { - return interaction.reply({ + return interaction.editReply({ content: "Unable to link your account to a ticket record. Please try again.", - ephemeral: true, }); } - await interaction.deferReply({ ephemeral: true }); - let targetUserId = await getUserIdByDiscordId(targetUser.id); if (!targetUserId) { @@ -835,7 +843,7 @@ export class SupportCommand extends Command { content: `Posted a Create Ticket panel in ${targetChannel} using the ${ ticketCategory ? `\`${ticketCategory.name}\`` : "default" } ticket category.`, - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } } diff --git a/controllers/userController.js b/controllers/userController.js index f62ac2ac..98d81714 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -558,7 +558,8 @@ export async function getUserPermissions(userData = {}) { const queuedRanks = []; const queuedRankSet = new Set(); - const userId = userData?.userId || null; + let userId = userData?.userId || null; + const discordId = userData?.discordId || null; // rawUuid is the LP-native UUID string (VARCHAR with dashes, e.g. "550e8400-e29b-41d4-a716-446655440000"). // LuckPerms MySQL stores uuid as VARCHAR(36) with dashes, so LP queries must use this value // directly rather than UNHEX(hex-without-dashes), which would produce binary that never matches. @@ -573,6 +574,21 @@ export async function getUserPermissions(userData = {}) { return; } + if (discordId) { + const rows = await runQuery( + `SELECT userId, uuid FROM users WHERE discordId = ? LIMIT 1`, + [discordId] + ); + + if (rows.length) { + userId = rows[0].userId; + if (rows[0].uuid) { + rawUuid = rows[0].uuid; + uuidHex = normaliseUuid(rows[0].uuid); + } + } + } + if (userId) { const rows = await runQuery( `SELECT uuid FROM users WHERE userId = ? LIMIT 1`, diff --git a/controllers/watchController.js b/controllers/watchController.js index 0a5e997d..49b497b5 100644 --- a/controllers/watchController.js +++ b/controllers/watchController.js @@ -335,7 +335,6 @@ async function hasCreatorPermission(uuid) { [dashedUuid, CREATOR_PERMISSION_NODE] ); if (direct.length > 0) { - console.log(`[Watch] uuid=${dashedUuid}: direct permission grant found.`); return true; } } catch (err) { @@ -352,7 +351,6 @@ async function hasCreatorPermission(uuid) { [dashedUuid] ); groups = groupRows.map((r) => r.grp); - console.log(`[Watch] uuid=${dashedUuid}: LP groups=[${groups.join(", ")}]`); } catch (err) { console.warn(`[Watch] uuid=${dashedUuid}: group-membership LP query failed —`, err.message); } @@ -366,7 +364,6 @@ async function hasCreatorPermission(uuid) { if (playerRow.length > 0 && playerRow[0].primary_group) { const pg = playerRow[0].primary_group; if (!groups.includes(pg)) groups.push(pg); - console.log(`[Watch] uuid=${dashedUuid}: primary_group="${pg}"`); } } catch (err) { console.warn(`[Watch] uuid=${dashedUuid}: luckperms_players query failed —`, err.message); @@ -385,7 +382,6 @@ async function hasCreatorPermission(uuid) { [...groups, CREATOR_PERMISSION_NODE] ); if (groupPerm.length > 0) { - console.log(`[Watch] uuid=${dashedUuid}: permission found on group "${groupPerm[0].name}".`); return true; } } catch (err) { @@ -403,14 +399,12 @@ async function hasCreatorPermission(uuid) { ); const parentGroups = [...new Set(parentRows.map((r) => r.parent))]; if (parentGroups.length > 0) { - console.log(`[Watch] uuid=${dashedUuid}: inherited parent groups=[${parentGroups.join(", ")}]`); const ph2 = parentGroups.map(() => "?").join(", "); const inheritedPerm = await runLpQuery( `SELECT name FROM luckperms_group_permissions WHERE name IN (${ph2}) AND permission=? AND value=1 LIMIT 1`, [...parentGroups, CREATOR_PERMISSION_NODE] ); if (inheritedPerm.length > 0) { - console.log(`[Watch] uuid=${dashedUuid}: permission found on inherited group "${inheritedPerm[0].name}".`); return true; } } @@ -434,15 +428,10 @@ export async function getEligibleCreators(platform) { [platform] ); - console.log(`[Watch] getEligibleCreators(${platform}): ${rows.length} active connection(s) found.`); - const eligible = []; for (const row of rows) { try { - console.log(`[Watch] userId=${row.userId} (${row.username}): uuid=${row.uuid || "(none)"}`); - const hasCreatorPerm = await hasCreatorPermission(row.uuid); - console.log(`[Watch] userId=${row.userId} (${row.username}): ${hasCreatorPerm ? "ELIGIBLE" : "not eligible"} for ${CREATOR_PERMISSION_NODE}`); if (hasCreatorPerm) { eligible.push(row); } @@ -451,7 +440,6 @@ export async function getEligibleCreators(platform) { } } - console.log(`[Watch] getEligibleCreators(${platform}): ${eligible.length}/${rows.length} creator(s) eligible.`); return eligible; } diff --git a/lib/discord/ticketFlow.mjs b/lib/discord/ticketFlow.mjs index 5f051544..dd6934f2 100644 --- a/lib/discord/ticketFlow.mjs +++ b/lib/discord/ticketFlow.mjs @@ -7,6 +7,7 @@ import { ButtonStyle, ComponentType, EmbedBuilder, + MessageFlags, ModalBuilder, PermissionFlagsBits, StringSelectMenuBuilder, @@ -36,7 +37,7 @@ export async function startTicketFlow(interaction, { parentCategoryId = null } = : null; if (!interaction.deferred && !interaction.replied) { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); } const categories = await getSupportCategories(); @@ -149,11 +150,11 @@ export async function startTicketFlow(interaction, { parentCategoryId = null } = time: 60000, }); } catch { - return selection.followUp({ content: "Ticket creation timed out.", ephemeral: true }); + return selection.followUp({ content: "Ticket creation timed out.", flags: [MessageFlags.Ephemeral] }); } if (!modalInteraction.deferred && !modalInteraction.replied) { - await modalInteraction.deferReply({ ephemeral: true }); + await modalInteraction.deferReply({ flags: [MessageFlags.Ephemeral] }); } const subject = modalInteraction.fields.getTextInputValue("subject"); @@ -243,7 +244,7 @@ export async function handleTicketClose(interaction) { if (!ticketDetails) { return interaction.reply({ content: "This channel is not linked to a ticket.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } @@ -257,7 +258,7 @@ export async function handleTicketClose(interaction) { if (!isStaff && !isOwner) { return interaction.reply({ content: "Only ticket staff or the ticket owner can close this ticket.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } @@ -276,7 +277,7 @@ export async function handleTicketClose(interaction) { content: "Are you sure you want to close this ticket? The channel will lock and delete 5 seconds after confirmation.", components: [confirmRow], - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } @@ -286,7 +287,7 @@ export async function handleTicketCloseConfirmation(interaction) { if (channelId && channelId !== interaction.channel.id) { return interaction.reply({ content: "This close confirmation does not match this channel.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } @@ -325,7 +326,7 @@ async function performTicketClose(interaction) { if (!ticketDetails) { return interaction.followUp({ content: "This channel is not linked to a ticket.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } @@ -339,7 +340,7 @@ async function performTicketClose(interaction) { if (!isStaff && !isOwner) { return interaction.followUp({ content: "Only ticket staff or the ticket owner can close this ticket.", - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); } diff --git a/routes/support.js b/routes/support.js index f7c8f21c..3abbee65 100644 --- a/routes/support.js +++ b/routes/support.js @@ -330,8 +330,9 @@ export default function supportRoutes( participants.groups.some((group) => group.rankSlug === slug) ); const isAppeal = /Appeal #/i.test(ticket.title || ""); - const canManageParticipants = isStaff || !isAppeal; - const canManageTicket = isOwner || isStaff || isParticipantUser || isParticipantRank; + const hasParticipantManagePermission = userHasPermissionNode(permissions, "zander.web.tickets.manageparticipants"); + const canManageParticipants = isStaff || hasParticipantManagePermission || !isAppeal; + const canManageTicket = isOwner || isStaff || isParticipantUser || isParticipantRank || hasParticipantManagePermission; if (!canManageTicket) { return res.redirect("/support"); @@ -412,8 +413,9 @@ export default function supportRoutes( const isParticipantRank = userRankSlugs.some((slug) => participants.groups.some((group) => group.rankSlug === slug) ); + const hasParticipantManagePermission = userHasPermissionNode(permissions, "zander.web.tickets.manageparticipants"); - if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank) { + if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank && !hasParticipantManagePermission) { return res.redirect("/support"); } @@ -821,8 +823,9 @@ export default function supportRoutes( const isParticipantRank = userRankSlugs.some((slug) => participants.groups.some((group) => group.rankSlug === slug) ); + const hasParticipantManagePermission = userHasPermissionNode(permissions, "zander.web.tickets.manageparticipants"); - if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank) { + if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank && !hasParticipantManagePermission) { return res.redirect("/support"); } @@ -891,8 +894,9 @@ export default function supportRoutes( const isParticipantRank = userRankSlugs.some((slug) => participants.groups.some((group) => group.rankSlug === slug) ); + const hasParticipantManagePermission = userHasPermissionNode(permissions, "zander.web.tickets.manageparticipants"); - if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank) { + if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank && !hasParticipantManagePermission) { return res.redirect("/support"); } @@ -956,8 +960,9 @@ export default function supportRoutes( const isParticipantRank = userRankSlugs.some((slug) => participants.groups.some((group) => group.rankSlug === slug) ); + const hasParticipantManagePermission = userHasPermissionNode(permissions, "zander.web.tickets.manageparticipants"); - if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank) { + if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank && !hasParticipantManagePermission) { return res.redirect("/support"); } @@ -1034,8 +1039,9 @@ export default function supportRoutes( const isParticipantRank = userRankSlugs.some((slug) => participants.groups.some((group) => group.rankSlug === slug) ); + const hasParticipantManagePermission = userHasPermissionNode(permissions, "zander.web.tickets.manageparticipants"); - if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank) { + if (!isOwner && !isStaff && !isParticipantUser && !isParticipantRank && !hasParticipantManagePermission) { return res.redirect("/support"); }