Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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

Expand All @@ -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 |
|---|---|
Expand All @@ -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

Expand All @@ -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).
Expand Down Expand Up @@ -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 |

90 changes: 49 additions & 41 deletions commands/support.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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.",
});
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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],
});
}
}
Expand Down
18 changes: 17 additions & 1 deletion controllers/userController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`,
Expand Down
Loading