refactor: initial moves

This commit is contained in:
syntaxbullet
2026-01-08 16:09:26 +01:00
parent 53a2f1ff0c
commit 88b266f81b
164 changed files with 529 additions and 280 deletions

View File

@@ -0,0 +1,93 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { config } from "@/lib/config";
import { getEnrollmentSuccessMessage } from "./enrollment.view";
import { classService } from "@shared/modules/class/class.service";
import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@/lib/errors";
import { sendWebhookMessage } from "@/lib/webhookUtils";
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
if (!interaction.inCachedGuild()) {
throw new UserError("This action can only be performed in a server.");
}
const { studentRole, visitorRole } = config;
if (!studentRole || !visitorRole) {
throw new UserError("No student or visitor role configured for enrollment.");
}
// 1. Ensure user exists in DB and check current enrollment status
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
throw new UserError("User profiles could not be loaded. Please try again later.");
}
// Check DB enrollment
if (user.class) {
throw new UserError("You are already enrolled in a class.");
}
const member = interaction.member;
// Check Discord role enrollment (Double safety)
if (member.roles.cache.has(studentRole)) {
throw new UserError("You already have the student role.");
}
// 2. Get available classes
const allClasses = await classService.getAllClasses();
const validClasses = allClasses.filter((c: any) => c.roleId);
if (validClasses.length === 0) {
throw new UserError("No classes with specified roles found in database.");
}
// 3. Pick random class
const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!;
const classRoleId = selectedClass.roleId!;
// Check if the role exists in the guild
const classRole = interaction.guild.roles.cache.get(classRoleId);
if (!classRole) {
throw new UserError(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`);
}
// 4. Perform Enrollment Actions
await member.roles.remove(visitorRole);
await member.roles.add(studentRole);
await member.roles.add(classRole);
// Persist to DB
await classService.assignClass(user.id.toString(), selectedClass.id);
await interaction.reply({
...getEnrollmentSuccessMessage(classRole.name),
flags: MessageFlags.Ephemeral
});
// 5. Send Welcome Message (if configured)
if (config.welcomeChannelId) {
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
if (welcomeChannel && welcomeChannel.isTextBased()) {
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
const processedMessage = rawMessage
.replace(/{user}/g, member.toString())
.replace(/{username}/g, member.user.username)
.replace(/{class}/g, selectedClass.name)
.replace(/{guild}/g, interaction.guild.name);
let payload;
try {
payload = JSON.parse(processedMessage);
} catch {
payload = processedMessage;
}
// Fire and forget webhook
sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment")
.catch((err: any) => console.error("Failed to send welcome message:", err));
}
}
}

View File

@@ -0,0 +1,12 @@
import { createErrorEmbed } from "@/lib/embeds";
export function getEnrollmentErrorEmbed(message: string, title: string = "Enrollment Failed") {
const embed = createErrorEmbed(message, title);
return { embeds: [embed] };
}
export function getEnrollmentSuccessMessage(roleName: string) {
return {
content: `🎉 You have been successfully enrolled! You received the **${roleName}** role.`
};
}

View File

@@ -0,0 +1,97 @@
import { userTimers } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { TimerType } from "@shared/lib/constants";
export { TimerType };
export const userTimerService = {
/**
* Set a timer for a user.
* Upserts the timer (updates expiration if exists).
*/
setTimer: async (userId: string, type: TimerType, key: string, durationMs: number, metadata: any = {}, tx?: any) => {
const execute = async (txFn: any) => {
const expiresAt = new Date(Date.now() + durationMs);
await txFn.insert(userTimers)
.values({
userId: BigInt(userId),
type,
key,
expiresAt,
metadata,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt, metadata }, // Update metadata too on re-set
});
return expiresAt;
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t: any) => {
return await execute(t);
});
}
},
/**
* Check if a timer is active (not expired).
* Returns true if ACTIVE.
*/
checkTimer: async (userId: string, type: TimerType, key: string, tx?: any): Promise<boolean> => {
const uniqueTx = tx || DrizzleClient; // Optimization: Read-only doesn't strictly need transaction wrapper overhead if single query
const timer = await uniqueTx.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, type),
eq(userTimers.key, key)
),
});
if (!timer) return false;
return timer.expiresAt > new Date();
},
/**
* Get timer details including metadata and expiry.
*/
getTimer: async (userId: string, type: TimerType, key: string, tx?: any) => {
const uniqueTx = tx || DrizzleClient;
return await uniqueTx.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, type),
eq(userTimers.key, key)
),
});
},
/**
* Manually clear a timer.
*/
clearTimer: async (userId: string, type: TimerType, key: string, tx?: any) => {
const execute = async (txFn: any) => {
await txFn.delete(userTimers)
.where(and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, type),
eq(userTimers.key, key)
));
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t: any) => {
return await execute(t);
});
}
}
};