Skip to content

Commit 0d2df14

Browse files
authored
Merge pull request #1 from HARRIFIED/moderator-role
Moderator role
2 parents c27a94d + 4c19216 commit 0d2df14

22 files changed

Lines changed: 661 additions & 48 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,5 @@ pids
5757

5858
# Diagnostic reports (https://nodejs.org/api/report.html)
5959
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
60+
61+
summary.txt

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@nestjs/websockets": "^10.4.20",
3737
"bcryptjs": "^3.0.3",
3838
"class-transformer": "^0.5.1",
39-
"class-validator": "^0.14.2",
39+
"class-validator": "^0.14.3",
4040
"ioredis": "^5.8.2",
4141
"pg": "^8.16.3",
4242
"reflect-metadata": "^0.2.0",

src/app.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { HealthModule } from './health/health.module';
2424
port: Number(process.env.DATABASE_PORT || 5432),
2525
username: process.env.DATABASE_USER || 'postgres',
2626
password: process.env.DATABASE_PASS || 'postgres',
27-
database: process.env.DATABASE_NAME || 'anonymous_chat',
27+
database: process.env.DATABASE_NAME || 'anonchat',
2828
synchronize: false,
2929
entities: [__dirname + '/**/*.entity.{js,ts}'],
3030
migrations: [__dirname + '/migrations/*.{js,ts}'],

src/auth/role.enum.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum Role {
2+
PARTICIPANT = 'PARTICIPANT',
3+
MODERATOR = 'MODERATOR'
4+
}

src/auth/session.service.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
22
import { v4 as uuidv4 } from 'uuid';
33
import * as crypto from 'crypto';
44
import { RedisService } from '../redis/redis.service';
5+
import { Role } from './role.enum';
56

67
@Injectable()
78
export class SessionService {
@@ -24,7 +25,8 @@ export class SessionService {
2425
username: string,
2526
avatar?: string,
2627
ip?: string,
27-
userAgent?: string
28+
userAgent?: string,
29+
role: Role = Role.PARTICIPANT
2830
) {
2931
const sessionId = uuidv4();
3032
const sessionToken = crypto.randomBytes(32).toString('hex');
@@ -40,19 +42,20 @@ export class SessionService {
4042
createdAt: new Date().toISOString(),
4143
ip: ip || null,
4244
userAgent: userAgent || null,
45+
role,
4346
};
4447

4548
const client = this.redisService.getClient();
4649
const ttl = Number(process.env.SESSION_TTL_SECONDS || 604800);
4750

48-
// CRITICAL FIX: Before creating new session, check for and destroy old sessions with same username
51+
// Before creating new session, check for and destroy old sessions with same username
4952
await this.destroyOldSessionsForUsername(roomId, username);
5053

5154
// Store session data
5255
await client.set(`${this.tokenPrefix}${sessionToken}`, sessionId, 'EX', ttl);
5356
await client.set(key, JSON.stringify(payload), 'EX', ttl);
5457

55-
// CRITICAL FIX: Map username to sessionId for quick lookup
58+
// Map username to sessionId for quick lookup
5659
const usernameSessionKey = `${this.usernameSessionPrefix}${roomId}:${username}`;
5760
await client.set(usernameSessionKey, sessionId, 'EX', ttl);
5861

@@ -129,6 +132,76 @@ export class SessionService {
129132
await client.del(`${this.tokenPrefix}${sessionToken}`);
130133
}
131134

135+
/**
136+
* Destroy a session by its sessionId (not token).
137+
* Removes session payload, any associated token keys, username mapping and presence entry.
138+
*/
139+
async destroySessionById(sessionId: string) {
140+
const client = this.redisService.getClient();
141+
const raw = await client.get(this.getSessionKey(sessionId));
142+
if (!raw) return;
143+
const sessionData = JSON.parse(raw);
144+
145+
// Remove session key
146+
await client.del(this.getSessionKey(sessionId));
147+
148+
// Remove any token that maps to this sessionId
149+
const tokenKeys = await client.keys(`${this.tokenPrefix}*`);
150+
for (const tokenKey of tokenKeys) {
151+
const sid = await client.get(tokenKey);
152+
if (sid === sessionId) {
153+
await client.del(tokenKey);
154+
}
155+
}
156+
157+
// Remove username -> session mapping
158+
if (sessionData && sessionData.roomId && sessionData.username) {
159+
const usernameSessionKey = `${this.usernameSessionPrefix}${sessionData.roomId}:${sessionData.username}`;
160+
await client.del(usernameSessionKey);
161+
162+
// Remove presence entry
163+
await client.hdel(`presence:${sessionData.roomId}`, sessionId);
164+
}
165+
}
166+
167+
/**
168+
* Destroy all sessions for a given room.
169+
* This will remove session records, tokens, username mappings and presence entries.
170+
*/
171+
async destroyAllSessionsForRoom(roomId: string) {
172+
const client = this.redisService.getClient();
173+
174+
const sessionIds = new Set<string>();
175+
176+
// 1) Collect sessionIds from presence hash (active connections)
177+
try {
178+
const pres = await client.hkeys(`presence:${roomId}`);
179+
pres.forEach((s) => sessionIds.add(s));
180+
} catch (e) {
181+
// ignore
182+
}
183+
184+
// 2) Also scan session keys for any sessions belonging to the room (covers disconnected but valid sessions)
185+
const sessKeys = await client.keys(`${this.sessionPrefix}*`);
186+
for (const k of sessKeys) {
187+
try {
188+
const raw = await client.get(k);
189+
if (!raw) continue;
190+
const parsed = JSON.parse(raw);
191+
if (parsed.roomId === roomId && parsed.sessionId) {
192+
sessionIds.add(parsed.sessionId);
193+
}
194+
} catch (e) {
195+
// ignore parse errors
196+
}
197+
}
198+
199+
for (const sid of sessionIds) {
200+
await this.destroySessionById(sid);
201+
this.logger.log(`Destroyed session ${sid} for room ${roomId}`);
202+
}
203+
}
204+
132205
/**
133206
* NEW: Get session by username and room
134207
*/

src/messages/entities/message.entity.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export class Message {
2121
@Column({ type: 'text', nullable: true })
2222
authorSessionId?: string;
2323

24+
@Column({ type: 'text', nullable: true })
25+
authorRole: string;
26+
2427
@Column({ type: 'text', nullable: true })
2528
clientMsgId?: string;
2629

src/messages/messages.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class MessagesService {
1818
content: payload.content,
1919
ip: payload.ip,
2020
userAgent: payload.userAgent,
21+
authorRole: payload.authorRole,
2122
});
2223
return this.msgRepo.save(m);
2324
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class AutoGenerated1765708320692 implements MigrationInterface {
4+
name = 'AutoGenerated1765708320692'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "rooms" ADD "creatorIP" text`);
8+
await queryRunner.query(`ALTER TABLE "rooms" ADD "creatorAgent" text`);
9+
await queryRunner.query(`ALTER TABLE "rooms" ADD "moderatorTokenHash" text`);
10+
await queryRunner.query(`ALTER TABLE "rooms" ADD "chatDurationSeconds" integer NOT NULL DEFAULT '3600'`);
11+
await queryRunner.query(`ALTER TABLE "rooms" ADD "actualParticipants" integer NOT NULL DEFAULT '0'`);
12+
await queryRunner.query(`ALTER TABLE "rooms" ADD "closedAt" TIMESTAMP WITH TIME ZONE`);
13+
await queryRunner.query(`ALTER TABLE "participants" ADD "anonymousId" text NOT NULL`);
14+
await queryRunner.query(`ALTER TABLE "messages" ADD "authorRole" text`);
15+
await queryRunner.query(`ALTER TABLE "rooms" ALTER COLUMN "title" SET NOT NULL`);
16+
}
17+
18+
public async down(queryRunner: QueryRunner): Promise<void> {
19+
await queryRunner.query(`ALTER TABLE "rooms" ALTER COLUMN "title" DROP NOT NULL`);
20+
await queryRunner.query(`ALTER TABLE "messages" DROP COLUMN "authorRole"`);
21+
await queryRunner.query(`ALTER TABLE "participants" DROP COLUMN "anonymousId"`);
22+
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "closedAt"`);
23+
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "actualParticipants"`);
24+
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "chatDurationSeconds"`);
25+
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "moderatorTokenHash"`);
26+
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "creatorAgent"`);
27+
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "creatorIP"`);
28+
}
29+
30+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class AutoGenerated1765719666318 implements MigrationInterface {
4+
name = 'AutoGenerated1765719666318'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "participants" RENAME COLUMN "isCreator" TO "isModerator"`);
8+
await queryRunner.query(`CREATE TABLE "room_bans" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "roomId" uuid NOT NULL, "username" text NOT NULL, "anonymousId" text NOT NULL, "ip" text NOT NULL, "userAgent" text NOT NULL, "bannedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_f9d925f0f4d6ce338e967a299e0" PRIMARY KEY ("id"))`);
9+
}
10+
11+
public async down(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(`DROP TABLE "room_bans"`);
13+
await queryRunner.query(`ALTER TABLE "participants" RENAME COLUMN "isModerator" TO "isCreator"`);
14+
}
15+
16+
}

src/participants/entities/participant.entity.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export class Participant {
1111
@Column({ type: 'text' })
1212
username: string;
1313

14+
@Column({ type: 'text' })
15+
anonymousId: string;
16+
1417
@Column({ type: 'text', nullable: true })
1518
avatar?: string;
1619

@@ -21,5 +24,5 @@ export class Participant {
2124
leftAt?: Date;
2225

2326
@Column({ default: false })
24-
isCreator: boolean;
27+
isModerator: boolean;
2528
}

0 commit comments

Comments
 (0)