Skip to content

Commit 5dc711d

Browse files
committed
feat: Include new queue service class.
This class will handle all queueing and checking if users are in queues via redis.
1 parent 9704e84 commit 5dc711d

File tree

8 files changed

+284
-6
lines changed

8 files changed

+284
-6
lines changed

config/development.json.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"uploadDemos": false,
1212
"localLoginEnabled": true,
1313
"redisUrl": "redis://:super_secure@localhost:6379",
14-
"redisTTL": 86400
14+
"redisTTL": 86400,
15+
"queueTTL": 3600
1516
},
1617
"development": {
1718
"driver": "mysql",

config/production.json.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"uploadDemos": $UPLOADDEMOS,
1212
"localLoginEnabled": $LOCALLOGINS,
1313
"redisUrl": "$REDISURL",
14-
"redisTTL": $REDISTTL
14+
"redisTTL": $REDISTTL,
15+
"queueTTL": $QUEUETTL
1516
},
1617
"production": {
1718
"driver": "mysql",

config/test.json.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"uploadDemos": false,
1212
"localLoginEnabled": true,
1313
"redisUrl": "redis://:super_secure@localhost:6379",
14-
"redisTTL": 86400
14+
"redisTTL": 86400,
15+
"queueTTL": 3600
1516
},
1617
"test": {
1718
"driver": "mysql",

src/services/queue.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import config from "config";
2+
import Utils from "../utility/utils.js";
3+
import { QueueDescriptor } from "../types/queues/QueueDescriptor.js"
4+
import { QueueItem } from "../types/queues/QueueItem.js";
5+
import { createClient } from "redis";
6+
7+
const redis = createClient({ url: config.get("server.redisUrl"), });
8+
const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : config.get("server.queueTTL");
9+
10+
export class QueueService {
11+
async createQueue(ttlSeconds = DEFAULT_TTL_SECONDS, ownerId?: string, maxPlayers: number = 10): Promise<QueueDescriptor> {
12+
let slug: string;
13+
let key: string;
14+
let attempts: number = 0;
15+
16+
do {
17+
slug = Utils.generateSlug();
18+
key = `queue:${slug}`;
19+
const exists = await redis.exists(key);
20+
if (!exists) break;
21+
attempts++;
22+
} while (attempts < 5);
23+
24+
if (attempts === 5) {
25+
throw new Error('Failed to generate a unique queue slug after 5 attempts.');
26+
}
27+
28+
const createdAt = Date.now();
29+
const expiresAt = createdAt + ttlSeconds * 1000;
30+
31+
const descriptor: QueueDescriptor = {
32+
name: slug,
33+
slug,
34+
createdAt,
35+
expiresAt,
36+
ownerId,
37+
maxSize: maxPlayers,
38+
isPrivate: false
39+
};
40+
41+
await redis.sAdd('queues', slug);
42+
await redis.expire(key, ttlSeconds);
43+
await redis.set(`queue-meta:${slug}`, JSON.stringify(descriptor), { EX: ttlSeconds });
44+
45+
return descriptor;
46+
}
47+
48+
async deleteQueue(
49+
slug: string,
50+
requesterSteamId: string,
51+
role: 'user' | 'admin' | 'super_admin'
52+
): Promise<void> {
53+
const key = `queue:${slug}`;
54+
const metaKey = `queue-meta:${slug}`;
55+
const meta = await getQueueMetaOrThrow(slug);
56+
57+
// Permission check
58+
const isOwner = meta.ownerId === requesterSteamId;
59+
const isAdmin = role === 'admin' || role === 'super_admin';
60+
61+
if (!isOwner && !isAdmin) {
62+
throw new Error('You do not have permission to delete this queue.');
63+
}
64+
65+
// Delete queue data
66+
await redis.del(key); // Remove queue list
67+
await redis.del(metaKey); // Remove metadata
68+
await redis.sRem('queues', slug); // Remove from global queue list
69+
}
70+
71+
async addUserToQueue(
72+
slug: string,
73+
steamId: string,
74+
requesterSteamId: string,
75+
role: 'user' | 'admin' | 'super_admin'
76+
): Promise<void> {
77+
const key = `queue:${slug}`;
78+
const meta = await getQueueMetaOrThrow(slug);
79+
80+
// Permission check
81+
if (
82+
role === 'user' &&
83+
steamId !== requesterSteamId &&
84+
meta.ownerId !== requesterSteamId
85+
) {
86+
throw new Error('You do not have permission to add other users to this queue.');
87+
}
88+
89+
const currentUsers = await redis.lRange(key, 0, -1);
90+
const alreadyInQueue = currentUsers.some((item: string) => {
91+
const parsed = JSON.parse(item);
92+
return parsed.steamId === steamId;
93+
});
94+
if (alreadyInQueue) {
95+
throw new Error(`Steam ID ${steamId} is already in the queue.`);
96+
}
97+
98+
if (meta.maxSize && currentUsers.length >= meta.maxSize) {
99+
throw new Error(`Queue ${slug} is full.`);
100+
}
101+
102+
const hltvRating = await Utils.getRatingFromSteamId(steamId);
103+
104+
const item: QueueItem = {
105+
steamId,
106+
timestamp: Date.now(),
107+
hltvRating: hltvRating ?? undefined
108+
};
109+
110+
await redis.rPush(key, JSON.stringify(item));
111+
}
112+
113+
async removeUserFromQueue(
114+
slug: string,
115+
steamId: string,
116+
requesterSteamId: string,
117+
role: 'user' | 'admin' | 'super_admin'
118+
): Promise<boolean> {
119+
const key = `queue:${slug}`;
120+
const meta = await getQueueMetaOrThrow(slug);
121+
122+
// Permission check
123+
if (
124+
role === 'user' &&
125+
steamId !== requesterSteamId &&
126+
meta.ownerId !== requesterSteamId
127+
) {
128+
throw new Error('You do not have permission to remove other users from this queue.');
129+
}
130+
131+
const currentUsers = await redis.lRange(key, 0, -1);
132+
for (const item of currentUsers) {
133+
const parsed = JSON.parse(item);
134+
if (parsed.steamId === steamId) {
135+
await redis.lRem(key, 1, item);
136+
return true;
137+
}
138+
}
139+
140+
return false;
141+
}
142+
143+
async listUsersInQueue(slug: string): Promise<QueueItem[]> {
144+
const key = `queue:${slug}`;
145+
const exists = await redis.exists(key);
146+
if (!exists) throw new Error(`Queue ${slug} does not exist or has expired.`);
147+
148+
const rawItems = await redis.lRange(key, 0, -1);
149+
return rawItems.map((item: string) => JSON.parse(item));
150+
}
151+
152+
async listQueues(requesterSteamId: string, role: 'user' | 'admin' | 'super_admin'): Promise<QueueDescriptor[]> {
153+
const slugs = await redis.sMembers('queues');
154+
const descriptors: QueueDescriptor[] = [];
155+
156+
for (const slug of slugs) {
157+
const metaRaw = await redis.get(`queue-meta:${slug}`);
158+
if (!metaRaw) continue;
159+
160+
const meta: QueueDescriptor = JSON.parse(metaRaw);
161+
162+
if (role === 'admin' || role === 'super_admin' || meta.ownerId === requesterSteamId) {
163+
descriptors.push(meta);
164+
}
165+
}
166+
167+
return descriptors;
168+
}
169+
170+
}
171+
172+
async function getQueueMetaOrThrow(slug: string): Promise<QueueDescriptor> {
173+
const key = `queue:${slug}`;
174+
const metaKey = `queue-meta:${slug}`;
175+
176+
const exists = await redis.exists(key);
177+
if (!exists) {
178+
throw new Error(`Queue ${slug} does not exist or has expired.`);
179+
}
180+
181+
const metaRaw = await redis.get(metaKey);
182+
if (!metaRaw) {
183+
throw new Error(`Queue metadata missing for ${slug}.`);
184+
}
185+
186+
return JSON.parse(metaRaw);
187+
}
188+
189+
export default QueueService;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface QueueDescriptor {
2+
name: string; // Human-readable name
3+
slug: string; // Unique identifier
4+
createdAt: number; // Timestamp (ms) when queue was created
5+
expiresAt: number; // Timestamp (ms) when queue will expire
6+
ownerId?: string; // Optional user ID of the queue creator
7+
maxSize?: number; // Optional max number of users allowed
8+
isPrivate?: boolean; // Optional flag for visibility
9+
}

src/types/queues/QueueItem.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface QueueItem {
2+
steamId: string;
3+
timestamp: number;
4+
hltvRating?: number;
5+
}

src/utility/utils.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,43 @@ class Utils {
8181
}
8282
}
8383

84+
/**
85+
* Fetches HLTV rating for a user by Steam ID.
86+
* @param steamId - The user's Steam ID
87+
* @returns HLTV rating or null if not found
88+
*/
89+
static async getRatingFromSteamId(steamId: string): Promise<number | null> {
90+
let playerStatSql =
91+
`SELECT steam_id, name, sum(kills) as kills,
92+
sum(deaths) as deaths, sum(assists) as assists, sum(k1) as k1,
93+
sum(k2) as k2, sum(k3) as k3,
94+
sum(k4) as k4, sum(k5) as k5, sum(v1) as v1,
95+
sum(v2) as v2, sum(v3) as v3, sum(v4) as v4,
96+
sum(v5) as v5, sum(roundsplayed) as trp, sum(flashbang_assists) as fba,
97+
sum(damage) as dmg, sum(headshot_kills) as hsk, count(id) as totalMaps,
98+
sum(knife_kills) as knifekills, sum(friendlies_flashed) as fflash,
99+
sum(enemies_flashed) as eflash, sum(util_damage) as utildmg
100+
FROM player_stats
101+
WHERE steam_id = ?
102+
AND match_id IN (
103+
SELECT id
104+
FROM \`match\`
105+
WHERE cancelled = 0)`;
106+
const user: RowDataPacket[] = await db.query(playerStatSql, [steamId]);;
107+
108+
if (!user.length) return null;
109+
110+
return this.getRating(parseFloat(user[0].kills),
111+
parseFloat(user[0].trp),
112+
parseFloat(user[0].deaths),
113+
parseFloat(user[0].k1),
114+
parseFloat(user[0].k2),
115+
parseFloat(user[0].k3),
116+
parseFloat(user[0].k4),
117+
parseFloat(user[0].k5));
118+
}
119+
120+
84121
/** Inner function - Supports encryption and decryption for the database keys to get server RCON passwords.
85122
* @name decrypt
86123
* @function
@@ -591,6 +628,40 @@ class Utils {
591628
}
592629
}
593630

631+
/**
632+
* Generates a Counter-Strike-style slug using themed adjectives and nouns,
633+
* including weapon skins and knife types.
634+
* Example: "clutch-karambit" or "dusty-dragonlore"
635+
*/
636+
public static generateSlug(): string {
637+
const adjectives = [
638+
'dusty', 'silent', 'brutal', 'clutch', 'smoky', 'tactical', 'deadly', 'stealthy',
639+
'eco', 'forceful', 'aggressive', 'defensive', 'sneaky', 'explosive', 'fraggy', 'nasty',
640+
'quick', 'slow', 'noisy', 'clean', 'dirty', 'sharp', 'blind', 'lucky',
641+
'fiery', 'cold', 'ghostly', 'venomous', 'royal'
642+
];
643+
644+
const nouns = [
645+
// Weapons & gameplay
646+
'ak47', 'deagle', 'bombsite', 'flashbang', 'knife', 'smoke', 'molotov', 'awp',
647+
'nade', 'scout', 'pistol', 'rifle', 'mid', 'long', 'short', 'connector',
648+
'ramp', 'hegrenade', 'tunnel', 'palace', 'apps', 'boost', 'peek', 'spray',
649+
650+
// Skins
651+
'dragonlore', 'fireserpent', 'hyperbeast', 'fade', 'casehardened', 'redline',
652+
'vulcan', 'asiimov', 'howl', 'bloodsport', 'phantomdisruptor', 'neonrider',
653+
654+
// Knives
655+
'karambit', 'bayonet', 'butterfly', 'gutknife', 'falchion', 'shadowdaggers',
656+
'huntsman', 'talon', 'ursus', 'paracord', 'nomad'
657+
];
658+
659+
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
660+
const noun = nouns[Math.floor(Math.random() * nouns.length)];
661+
662+
return `${adj}-${noun}`;
663+
}
664+
594665
public static addChallongeTeamAuthsToArray: (teamId: number, custom_field_response: { key: string; value: string; }) => Promise<void> = async (teamId: number, custom_field_response: { key: string, value: string }) => {
595666
let teamAuthArray: Array<Array<any>> = [];
596667
let key: keyof typeof custom_field_response;
@@ -608,6 +679,7 @@ class Utils {
608679
await db.query(sqlString, [teamAuthArray]);
609680
}
610681
}
682+
611683
}
612684

613685

yarn.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4945,9 +4945,9 @@ v8-to-istanbul@^9.0.1:
49454945
convert-source-map "^2.0.0"
49464946

49474947
validator@^13.7.0:
4948-
version "13.15.15"
4949-
resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.15.tgz#246594be5671dc09daa35caec5689fcd18c6e7e4"
4950-
integrity sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==
4948+
version "13.15.20"
4949+
resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.20.tgz#054e9238109538a1bf46ae3e1290845a64fa2186"
4950+
integrity sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==
49514951

49524952
vary@^1, vary@~1.1.2:
49534953
version "1.1.2"

0 commit comments

Comments
 (0)