fitaiProto/apps/realtime/src/index.ts
2026-04-03 03:25:11 +02:00

693 lines
16 KiB
TypeScript

import "dotenv/config";
import http from "node:http";
import express, { type Request, type Response } from "express";
import { Server as SocketIOServer } from "socket.io";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";
import { verifyToken } from "@clerk/backend";
import { db, sql } from "@fitai/database";
interface ThreadAccess {
id: string;
type: "gym" | "dm";
gymId: string | null;
trainerId: string | null;
clientId: string | null;
}
interface IncomingChatMessagePayload {
threadId?: string;
body?: string;
clientMessageId?: string;
attachments?: unknown;
}
interface AuthenticatedSocketData {
user: {
id: string;
role: "superAdmin" | "admin" | "trainer" | "client";
gymId: string | null;
};
}
const app = express();
const server = http.createServer(app);
const sendRateWindowByUser = new Map<string, number[]>();
const typingRateWindowByKey = new Map<string, number[]>();
const readRateWindowByKey = new Map<string, number[]>();
const SEND_LIMIT_PER_MINUTE = 30;
const TYPING_LIMIT_PER_10_SECONDS = 20;
const READ_LIMIT_PER_10_SECONDS = 30;
let redisRateClient: ReturnType<typeof createClient> | null = null;
const io = new SocketIOServer<
Record<string, never>,
Record<string, never>,
Record<string, never>,
AuthenticatedSocketData
>(server, {
cors: {
origin: process.env.REALTIME_CORS_ORIGIN?.split(",") ?? "*",
credentials: true,
},
path: process.env.REALTIME_SOCKET_PATH ?? "/socket.io",
});
app.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true, service: "realtime" });
});
async function attachRedisAdapter(): Promise<void> {
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
return;
}
const pubClient = createClient({ url: redisUrl });
const subClient = pubClient.duplicate();
await pubClient.connect();
await subClient.connect();
redisRateClient = pubClient;
io.adapter(createAdapter(pubClient, subClient));
}
io.use(async (socket: any, next: (err?: Error) => void) => {
try {
const token =
typeof socket.handshake.auth?.token === "string"
? socket.handshake.auth.token
: null;
const userId = await verifyAndExtractUserId(token);
if (!userId) {
return next(new Error("Unauthorized"));
}
const rows = await db.all(sql`
SELECT
id,
role,
gym_id as gymId
FROM users
WHERE id = ${userId}
LIMIT 1
`);
const user = rows?.[0] as
| {
id: string;
role: "superAdmin" | "admin" | "trainer" | "client";
gymId: string | null;
}
| undefined;
if (!user) {
return next(new Error("Unauthorized"));
}
socket.data.user = user;
return next();
} catch {
return next(new Error("Unauthorized"));
}
});
io.on("connection", (socket: any) => {
socket.on("chat:subscribe", async (payload: { threadId?: string }) => {
const threadId = payload?.threadId;
if (!threadId) {
socket.emit("chat:error", { code: "invalid_payload" });
return;
}
const allowed = await canAccessThread(socket.data.user, threadId);
if (!allowed) {
socket.emit("chat:error", { code: "forbidden" });
return;
}
await socket.join(`thread:${threadId}`);
socket.emit("chat:subscribed", { threadId });
});
socket.on("chat:unsubscribe", async (payload: { threadId?: string }) => {
const threadId = payload?.threadId;
if (!threadId) {
return;
}
await socket.leave(`thread:${threadId}`);
});
socket.on(
"chat:typing",
async (payload: { threadId?: string; isTyping?: boolean }) => {
const threadId = payload?.threadId;
if (!threadId) {
return;
}
const typingAllowed = await checkRateLimit(
typingRateWindowByKey,
`typing:${socket.data.user.id}:${threadId}`,
TYPING_LIMIT_PER_10_SECONDS,
10,
);
if (!typingAllowed) {
return;
}
const allowed = await canAccessThread(socket.data.user, threadId);
if (!allowed) {
return;
}
socket.to(`thread:${threadId}`).emit("chat:typing", {
threadId,
userId: socket.data.user.id,
isTyping: Boolean(payload?.isTyping),
});
},
);
socket.on("chat:send", async (payload: IncomingChatMessagePayload) => {
try {
const threadId =
typeof payload?.threadId === "string" ? payload.threadId : null;
const body = typeof payload?.body === "string" ? payload.body.trim() : "";
if (!threadId || !body) {
socket.emit("chat:error", {
code: "invalid_payload",
message: "threadId and body are required",
});
return;
}
const sendAllowed = await checkRateLimit(
sendRateWindowByUser,
`send:${socket.data.user.id}`,
SEND_LIMIT_PER_MINUTE,
60,
);
if (!sendAllowed) {
socket.emit("chat:error", {
code: "rate_limited",
message: "Too many messages. Please slow down.",
});
return;
}
if (body.length > 2000) {
socket.emit("chat:error", {
code: "message_too_long",
message: "Message is too long",
});
return;
}
const access = await getThreadAccess(socket.data.user, threadId);
if (!access) {
socket.emit("chat:error", { code: "forbidden", message: "Forbidden" });
return;
}
await ensureThreadMembership(
threadId,
socket.data.user.id,
socket.data.user.role,
);
const clientMessageId =
typeof payload.clientMessageId === "string" &&
payload.clientMessageId.trim().length > 0
? payload.clientMessageId.trim()
: null;
if (clientMessageId) {
const existingRows = await db.all(sql`
SELECT
id,
thread_id as threadId,
sender_user_id as senderUserId,
body,
kind,
attachments_json as attachmentsJson,
client_message_id as clientMessageId,
created_at as createdAt,
edited_at as editedAt,
deleted_at as deletedAt
FROM chat_messages
WHERE sender_user_id = ${socket.data.user.id}
AND client_message_id = ${clientMessageId}
LIMIT 1
`);
if (existingRows?.[0]) {
const message = mapMessageRow(existingRows[0] as MessageRow);
socket.emit("chat:message:ack", {
threadId,
clientMessageId,
message,
duplicate: true,
});
return;
}
}
const attachments = normalizeAttachments(payload.attachments);
const now = nowEpochSeconds();
const messageId = generateId("msg");
await db.run(sql`
INSERT INTO chat_messages (
id,
thread_id,
sender_user_id,
body,
kind,
attachments_json,
client_message_id,
created_at
) VALUES (
${messageId},
${threadId},
${socket.data.user.id},
${body},
${"text"},
${attachments.length > 0 ? JSON.stringify(attachments) : null},
${clientMessageId},
${now}
)
`);
await db.run(sql`
UPDATE chat_threads
SET
last_message_at = ${now},
updated_at = ${now}
WHERE id = ${threadId}
`);
const createdRows = await db.all(sql`
SELECT
id,
thread_id as threadId,
sender_user_id as senderUserId,
body,
kind,
attachments_json as attachmentsJson,
client_message_id as clientMessageId,
created_at as createdAt,
edited_at as editedAt,
deleted_at as deletedAt
FROM chat_messages
WHERE id = ${messageId}
LIMIT 1
`);
const createdRow = createdRows?.[0] as MessageRow | undefined;
if (!createdRow) {
socket.emit("chat:error", {
code: "send_failed",
message: "Failed to persist message",
});
return;
}
const message = mapMessageRow(createdRow);
await socket.join(`thread:${threadId}`);
io.to(`thread:${threadId}`).emit("chat:message:new", {
threadId,
message,
});
io.to(`thread:${threadId}`).emit("chat:read:update", {
threadId,
userId: socket.data.user.id,
lastReadMessageId: message.id,
});
socket.emit("chat:message:ack", {
threadId,
clientMessageId,
message,
});
} catch (error) {
socket.emit("chat:error", {
code: "internal_error",
message: error instanceof Error ? error.message : "Failed to send",
});
}
});
socket.on(
"chat:read",
async (payload: { threadId?: string; lastReadMessageId?: string }) => {
const threadId = payload?.threadId;
if (!threadId) {
return;
}
const readAllowed = await checkRateLimit(
readRateWindowByKey,
`read:${socket.data.user.id}:${threadId}`,
READ_LIMIT_PER_10_SECONDS,
10,
);
if (!readAllowed) {
return;
}
const allowed = await canAccessThread(socket.data.user, threadId);
if (!allowed) {
return;
}
await ensureThreadMembership(
threadId,
socket.data.user.id,
socket.data.user.role,
);
const now = Math.floor(Date.now() / 1000);
await db.run(sql`
UPDATE chat_thread_members
SET
last_read_message_id = ${payload?.lastReadMessageId ?? null},
last_read_at = ${now}
WHERE thread_id = ${threadId}
AND user_id = ${socket.data.user.id}
`);
io.to(`thread:${threadId}`).emit("chat:read:update", {
threadId,
userId: socket.data.user.id,
lastReadMessageId: payload?.lastReadMessageId ?? null,
});
},
);
});
async function canAccessThread(
user: AuthenticatedSocketData["user"],
threadId: string,
): Promise<boolean> {
const access = await getThreadAccess(user, threadId);
return Boolean(access);
}
async function getThreadAccess(
user: AuthenticatedSocketData["user"],
threadId: string,
): Promise<ThreadAccess | null> {
const rows = await db.all(sql`
SELECT
id,
type,
gym_id as gymId,
trainer_id as trainerId,
client_id as clientId,
archived_at as archivedAt
FROM chat_threads
WHERE id = ${threadId}
LIMIT 1
`);
const thread = rows?.[0] as
| {
id: string;
type: "gym" | "dm";
gymId: string | null;
trainerId: string | null;
clientId: string | null;
archivedAt: number | null;
}
| undefined;
if (!thread || thread.archivedAt) {
return null;
}
if (user.role === "superAdmin") {
return {
id: thread.id,
type: thread.type,
gymId: thread.gymId,
trainerId: thread.trainerId,
clientId: thread.clientId,
};
}
if (thread.type === "gym") {
if (!user.gymId || user.gymId !== thread.gymId) {
return null;
}
return {
id: thread.id,
type: thread.type,
gymId: thread.gymId,
trainerId: thread.trainerId,
clientId: thread.clientId,
};
}
const isParticipant =
user.id === thread.trainerId || user.id === thread.clientId;
if (!isParticipant) {
return null;
}
if (thread.trainerId && thread.clientId) {
const assignmentRows = await db.all(sql`
SELECT id
FROM trainer_client_assignments
WHERE trainer_id = ${thread.trainerId}
AND client_id = ${thread.clientId}
AND is_active = 1
LIMIT 1
`);
if (!assignmentRows?.[0]) {
return null;
}
}
return {
id: thread.id,
type: thread.type,
gymId: thread.gymId,
trainerId: thread.trainerId,
clientId: thread.clientId,
};
}
async function verifyAndExtractUserId(
token: string | null,
): Promise<string | null> {
if (!token) {
return null;
}
const secretKey = process.env.CLERK_SECRET_KEY;
if (!secretKey) {
return null;
}
try {
const verified = await verifyToken(token, {
secretKey,
clockSkewInMs: 5000,
});
if (typeof verified.sub === "string" && verified.sub.trim().length > 0) {
return verified.sub;
}
const fallback =
typeof verified.sid === "string" && verified.sid.trim().length > 0
? verified.sid
: null;
if (fallback) {
const rows = await db.all(sql`
SELECT id
FROM users
WHERE id = ${fallback}
LIMIT 1
`);
if (rows?.[0]) {
return fallback;
}
}
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64url").toString(),
);
if (typeof payload.sub === "string" && payload.sub.trim().length > 0) {
return payload.sub;
}
return null;
} catch {
return null;
}
}
async function main() {
await attachRedisAdapter();
const port = Number(process.env.REALTIME_PORT ?? 3001);
server.listen(port, () => {
console.log(`[realtime] listening on :${port}`);
});
}
async function ensureThreadMembership(
threadId: string,
userId: string,
role: AuthenticatedSocketData["user"]["role"],
): Promise<void> {
const now = nowEpochSeconds();
await db.run(sql`
INSERT OR IGNORE INTO chat_thread_members (
id,
thread_id,
user_id,
role_in_thread,
joined_at,
muted
) VALUES (
${generateId("member")},
${threadId},
${userId},
${role},
${now},
${0}
)
`);
await db.run(sql`
UPDATE chat_thread_members
SET left_at = NULL
WHERE thread_id = ${threadId}
AND user_id = ${userId}
`);
}
function normalizeAttachments(
raw: unknown,
): Array<{ url: string; type: string; name?: string }> {
if (!Array.isArray(raw)) {
return [];
}
return raw
.filter(
(item): item is { url: string; type: string; name?: string } =>
item !== null &&
typeof item === "object" &&
typeof (item as { url?: unknown }).url === "string" &&
typeof (item as { type?: unknown }).type === "string",
)
.map((item) => ({
url: item.url,
type: item.type,
name: typeof item.name === "string" ? item.name : undefined,
}));
}
function parseAttachments(
value: string | null,
): Array<{ url: string; type: string; name?: string }> {
if (!value) {
return [];
}
try {
return normalizeAttachments(JSON.parse(value));
} catch {
return [];
}
}
function mapMessageRow(row: MessageRow) {
return {
id: row.id,
threadId: row.threadId,
senderUserId: row.senderUserId,
body: row.body,
kind: row.kind,
attachments: parseAttachments(row.attachmentsJson),
clientMessageId: row.clientMessageId,
createdAt: new Date(Number(row.createdAt) * 1000).toISOString(),
editedAt: row.editedAt
? new Date(Number(row.editedAt) * 1000).toISOString()
: null,
deletedAt: row.deletedAt
? new Date(Number(row.deletedAt) * 1000).toISOString()
: null,
};
}
function generateId(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
function nowEpochSeconds(): number {
return Math.floor(Date.now() / 1000);
}
export async function checkRateLimit(
windows: Map<string, number[]>,
key: string,
limit: number,
windowSeconds: number,
): Promise<boolean> {
if (redisRateClient) {
try {
const redisKey = `fitai:chat:rl:${key}`;
const count = await redisRateClient.incr(redisKey);
if (count === 1) {
await redisRateClient.expire(redisKey, windowSeconds);
}
return count <= limit;
} catch {
// Continue with in-memory fallback below.
}
}
const now = Date.now();
const current = windows.get(key) ?? [];
const windowMs = windowSeconds * 1000;
const withinWindow = current.filter(
(timestamp) => now - timestamp < windowMs,
);
if (withinWindow.length >= limit) {
windows.set(key, withinWindow);
return false;
}
withinWindow.push(now);
windows.set(key, withinWindow);
return true;
}
interface MessageRow {
id: string;
threadId: string;
senderUserId: string;
body: string;
kind: "text" | "system";
attachmentsJson: string | null;
clientMessageId: string | null;
createdAt: number;
editedAt: number | null;
deletedAt: number | null;
}
void main();