import { log } from '$lib/server/logger'; import { dev } from '$app/environment'; import { db } from '$lib/server/db'; import { matchInvite, teamMember } from '$lib/server/db/schema'; import { user } from '$lib/server/db/auth.schema'; import { eq, and, lt, inArray, desc } from 'drizzle-orm'; import { createNotification, markActionedByReference } from '$lib/server/notifications'; import { createMatch } from './engine'; import type { MatchConfig } from './types'; import { sendDM } from '$lib/server/bancho/client'; const DEFAULT_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours export async function createInvite(opts: { createdBy: string; creatorTeamId: string; invitedTeamId: string; config: MatchConfig; mappoolId: string; name?: string; message?: string; scheduledAt?: Date | null; }) { const { createdBy, creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = opts; if (creatorTeamId === invitedTeamId && !dev) { throw new Error('Cannot invite your own team'); } // Verify user is on creator team const membership = await db.query.teamMember.findFirst({ where: and(eq(teamMember.userId, createdBy), eq(teamMember.teamId, creatorTeamId)) }); if (!membership) throw new Error('You are not a member of the selected team'); // Verify invited team exists and has members const invitedMembers = await db.query.teamMember.findMany({ where: eq(teamMember.teamId, invitedTeamId) }); if (invitedMembers.length === 0) throw new Error('Invited team has no members'); const baseTime = scheduledAt ?? new Date(); const expiresAt = new Date(baseTime.getTime() + DEFAULT_EXPIRY_MS); const [invite] = await db .insert(matchInvite) .values({ createdBy, creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt, expiresAt, status: 'pending' }) .returning(); // Create notification for each invited team member const configSummary = `BO${config.bestOf} ${config.teamSizes ? config.teamSizes.join('v') : `${config.teamSize}v${config.teamSize}`} ${config.scoringType}`; const title = `Match invite: ${name || configSummary}`; const notifMessage = message || `You've been challenged to a ${configSummary} match`; for (const member of invitedMembers) { await createNotification(member.userId, 'match_invite', title, notifMessage, invite.id); } // Send Bancho DMs (best-effort) for (const member of invitedMembers) { const u = await db.query.user.findFirst({ where: eq(user.id, member.userId) }); if (u?.name) { sendDM( u.name, `Match invite: ${title}. Accept/decline at https://esports.vash.software/matches or reply !accept ${invite.id.slice(0, 8)}` ).catch(() => {}); // Silent best-effort } } return invite; } export async function acceptInvite(inviteId: string, userId: string) { await expireStaleInvites(); const invite = await db.query.matchInvite.findFirst({ where: eq(matchInvite.id, inviteId), with: { creatorTeam: true, invitedTeam: true, mappool: true } }); if (!invite) throw new Error('Invite not found'); if (invite.status !== 'pending') throw new Error(`Invite is ${invite.status}`); // Verify user is on invited team const membership = await db.query.teamMember.findFirst({ where: and(eq(teamMember.userId, userId), eq(teamMember.teamId, invite.invitedTeamId)) }); if (!membership) throw new Error('You are not on the invited team'); // Check if expired if (new Date() > invite.expiresAt) { await db.update(matchInvite).set({ status: 'expired' }).where(eq(matchInvite.id, inviteId)); throw new Error('Invite has expired'); } const config = invite.config as MatchConfig; // Create the match const created = await createMatch({ name: invite.name || `Custom: ${invite.creatorTeam.name} vs ${invite.invitedTeam.name}`, config, mappoolId: invite.mappoolId, teams: [invite.creatorTeamId, invite.invitedTeamId], createdBy: invite.createdBy }); // Update invite await db .update(matchInvite) .set({ status: 'accepted', matchId: created.id, respondedAt: new Date() }) .where(eq(matchInvite.id, inviteId)); // Mark notifications as actioned for all invited team members const invitedMembers = await db.query.teamMember.findMany({ where: eq(teamMember.teamId, invite.invitedTeamId) }); for (const member of invitedMembers) { await markActionedByReference(inviteId, member.userId); } // Notify creator await createNotification( invite.createdBy, 'invite_accepted', `${invite.invitedTeam.name} accepted your match invite`, undefined, created.id ); // Start lobby if immediate (no scheduledAt) if (!invite.scheduledAt) { try { const { initMatchLobby } = await import('./orchestrator'); initMatchLobby(created.id).catch((err) => { log.invites.error({ err, matchId: created.id }, 'IRC lobby creation failed'); }); } catch (err: any) { log.invites.error({ err }, 'failed to import orchestrator'); } } return created; } export async function declineInvite(inviteId: string, userId: string) { const invite = await db.query.matchInvite.findFirst({ where: eq(matchInvite.id, inviteId), with: { invitedTeam: true } }); if (!invite) throw new Error('Invite not found'); if (invite.status !== 'pending') throw new Error(`Invite is ${invite.status}`); const membership = await db.query.teamMember.findFirst({ where: and(eq(teamMember.userId, userId), eq(teamMember.teamId, invite.invitedTeamId)) }); if (!membership) throw new Error('You are not on the invited team'); await db .update(matchInvite) .set({ status: 'declined', respondedAt: new Date() }) .where(eq(matchInvite.id, inviteId)); // Mark notifications as actioned const invitedMembers = await db.query.teamMember.findMany({ where: eq(teamMember.teamId, invite.invitedTeamId) }); for (const member of invitedMembers) { await markActionedByReference(inviteId, member.userId); } // Notify creator await createNotification( invite.createdBy, 'invite_declined', `${invite.invitedTeam.name} declined your match invite`, undefined, inviteId ); } export async function cancelInvite(inviteId: string, userId: string) { const invite = await db.query.matchInvite.findFirst({ where: eq(matchInvite.id, inviteId) }); if (!invite) throw new Error('Invite not found'); if (invite.status !== 'pending') throw new Error(`Invite is ${invite.status}`); if (invite.createdBy !== userId) throw new Error('Only the creator can cancel an invite'); await db .update(matchInvite) .set({ status: 'cancelled', respondedAt: new Date() }) .where(eq(matchInvite.id, inviteId)); } export async function getInvitesForUser(userId: string) { await expireStaleInvites(); // Find all teams the user belongs to const memberships = await db.query.teamMember.findMany({ where: eq(teamMember.userId, userId) }); const teamIds = memberships.map((m) => m.teamId); if (teamIds.length === 0) return { sent: [], received: [] }; const sent = await db.query.matchInvite.findMany({ where: eq(matchInvite.createdBy, userId), with: { creatorTeam: true, invitedTeam: true, mappool: true }, orderBy: desc(matchInvite.createdAt), limit: 20 }); const received = await db.query.matchInvite.findMany({ where: inArray(matchInvite.invitedTeamId, teamIds), with: { creatorTeam: true, invitedTeam: true, mappool: true }, orderBy: desc(matchInvite.createdAt), limit: 20 }); return { sent, received }; } export async function expireStaleInvites() { const now = new Date(); await db .update(matchInvite) .set({ status: 'expired' }) .where(and(eq(matchInvite.status, 'pending'), lt(matchInvite.expiresAt, now))); }