chat implemented
need testing
This commit is contained in:
1 parent
c90f8cb1fa
commit
9cbdc35903
40 files changed
+7068
-10
No files matched your search
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET as GET_THREADS } from "../threads/route";
|
||||
import { POST as START_DM } from "../dm/start/route";
|
||||
import { GET as GET_MY_DM } from "../dm/my-thread/route";
|
||||
|
||||
jest.mock("@clerk/nextjs/server", () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/database", () => ({
|
||||
getDatabase: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/sync-user", () => ({
|
||||
ensureUserSynced: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/chat", () => ({
|
||||
getOrCreateGymRoomThread: jest.fn(),
|
||||
listThreadsForUser: jest.fn(),
|
||||
getOrCreateDmThread: jest.fn(),
|
||||
isChatError: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/logger", () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("/api/chat authz", () => {
|
||||
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
|
||||
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
|
||||
const mockEnsureUserSynced = require("@/lib/sync-user")
|
||||
.ensureUserSynced as jest.Mock;
|
||||
const mockListThreadsForUser = require("@/lib/chat")
|
||||
.listThreadsForUser as jest.Mock;
|
||||
const mockGetOrCreateDmThread = require("@/lib/chat")
|
||||
.getOrCreateDmThread as jest.Mock;
|
||||
|
||||
const mockDb = {
|
||||
getClientTrainerAssignment: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetDatabase.mockResolvedValue(mockDb);
|
||||
mockListThreadsForUser.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns 401 for unauthenticated threads list", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: null });
|
||||
|
||||
const req = new NextRequest("http://localhost/api/chat/threads");
|
||||
const res = await GET_THREADS(req);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when non participant/non staff starts DM", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "user_1",
|
||||
role: "trainer",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
|
||||
const req = new NextRequest("http://localhost/api/chat/dm/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ trainerId: "trainer_2", clientId: "client_2" }),
|
||||
});
|
||||
|
||||
const res = await START_DM(req);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockGetOrCreateDmThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 when non-client requests my-thread endpoint", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "trainer_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "trainer_1",
|
||||
role: "trainer",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
|
||||
const res = await GET_MY_DM();
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when client has no active assignment", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "client_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "client_1",
|
||||
role: "client",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
mockDb.getClientTrainerAssignment.mockResolvedValue(null);
|
||||
|
||||
const res = await GET_MY_DM();
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { getOrCreateDmThread, isChatError } from "@/lib/chat";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (currentUser.role !== "client") {
|
||||
return NextResponse.json(
|
||||
{ error: "Only clients can use this endpoint" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const assignment = await db.getClientTrainerAssignment(currentUser.id);
|
||||
if (!assignment || !assignment.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: "No active trainer assignment found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const thread = await getOrCreateDmThread({
|
||||
trainerId: assignment.trainerId,
|
||||
clientId: assignment.clientId,
|
||||
createdBy: {
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ thread });
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to get client DM thread", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { getOrCreateDmThread, isChatError } from "@/lib/chat";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const trainerId = String(body.trainerId ?? "").trim();
|
||||
const clientId = String(body.clientId ?? "").trim();
|
||||
|
||||
if (!trainerId || !clientId) {
|
||||
return NextResponse.json(
|
||||
{ error: "trainerId and clientId are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const isStaff =
|
||||
currentUser.role === "superAdmin" || currentUser.role === "admin";
|
||||
const isParticipant =
|
||||
currentUser.id === trainerId || currentUser.id === clientId;
|
||||
|
||||
if (!isStaff && !isParticipant) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const thread = await getOrCreateDmThread({
|
||||
trainerId,
|
||||
clientId,
|
||||
createdBy: {
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ thread }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to start direct message thread", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { getOrCreateGymRoomThread, isChatError } from "@/lib/chat";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const thread = await getOrCreateGymRoomThread({
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ thread });
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to get gym room thread", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
jest.mock("@clerk/nextjs/server", () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/database", () => ({
|
||||
getDatabase: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/sync-user", () => ({
|
||||
ensureUserSynced: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/chat", () => ({
|
||||
getThreadMessages: jest.fn(),
|
||||
createThreadMessage: jest.fn(),
|
||||
isChatError: jest.fn((error: unknown) =>
|
||||
Boolean(
|
||||
error && typeof error === "object" && "status" in (error as object),
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/logger", () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("/api/chat/threads/[id]/messages authz", () => {
|
||||
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
|
||||
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
|
||||
const mockEnsureUserSynced = require("@/lib/sync-user")
|
||||
.ensureUserSynced as jest.Mock;
|
||||
const mockGetThreadMessages = require("@/lib/chat")
|
||||
.getThreadMessages as jest.Mock;
|
||||
const mockCreateThreadMessage = require("@/lib/chat")
|
||||
.createThreadMessage as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetDatabase.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("returns 401 for unauthenticated GET", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: null });
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
);
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when synced user is missing on GET", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
);
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockGetThreadMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps ChatError to status code on GET", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "user_1",
|
||||
role: "client",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
mockGetThreadMessages.mockRejectedValue({
|
||||
status: 403,
|
||||
message: "Forbidden",
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
);
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 401 for unauthenticated POST", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: null });
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "Hello" }),
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 400 when POST body is invalid", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: "not json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 403 when synced user is missing on POST", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "Hi" }),
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockCreateThreadMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps ChatError to status code on POST", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "user_1",
|
||||
role: "client",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
mockCreateThreadMessage.mockRejectedValue({
|
||||
status: 403,
|
||||
message: "Forbidden",
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "Hello" }),
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
jest.mock("@clerk/nextjs/server", () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/database", () => ({
|
||||
getDatabase: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/sync-user", () => ({
|
||||
ensureUserSynced: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/chat", () => ({
|
||||
getThreadMessages: jest.fn(),
|
||||
createThreadMessage: jest.fn(),
|
||||
isChatError: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/logger", () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("/api/chat/threads/[id]/messages params", () => {
|
||||
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
|
||||
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
|
||||
const mockEnsureUserSynced = require("@/lib/sync-user")
|
||||
.ensureUserSynced as jest.Mock;
|
||||
const mockGetThreadMessages = require("@/lib/chat")
|
||||
.getThreadMessages as jest.Mock;
|
||||
const mockCreateThreadMessage = require("@/lib/chat")
|
||||
.createThreadMessage as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockGetDatabase.mockResolvedValue({});
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "user_1",
|
||||
role: "client",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses numeric cursor and limit", async () => {
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], nextCursor: null });
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages?cursor=12345&limit=20",
|
||||
);
|
||||
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetThreadMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread_1",
|
||||
cursor: 12345,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores invalid cursor and limit query values", async () => {
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], nextCursor: null });
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages?cursor=abc&limit=xyz",
|
||||
);
|
||||
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetThreadMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread_1",
|
||||
cursor: undefined,
|
||||
limit: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters malformed attachments before create", async () => {
|
||||
mockCreateThreadMessage.mockResolvedValue({ id: "msg_1" });
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: "Hello",
|
||||
attachments: [
|
||||
{ url: "https://example.com/file.png", type: "image/png" },
|
||||
{ url: 123, type: "image/png" },
|
||||
{ foo: "bar" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockCreateThreadMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread_1",
|
||||
body: "Hello",
|
||||
attachments: [
|
||||
{
|
||||
url: "https://example.com/file.png",
|
||||
type: "image/png",
|
||||
name: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import {
|
||||
createThreadMessage,
|
||||
getThreadMessages,
|
||||
isChatError,
|
||||
} from "@/lib/chat";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: threadId } = await params;
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const cursorParam = request.nextUrl.searchParams.get("cursor");
|
||||
const limitParam = request.nextUrl.searchParams.get("limit");
|
||||
|
||||
const cursor =
|
||||
cursorParam && Number.isFinite(Number(cursorParam))
|
||||
? Number(cursorParam)
|
||||
: undefined;
|
||||
const limit =
|
||||
limitParam && Number.isFinite(Number(limitParam))
|
||||
? Number(limitParam)
|
||||
: undefined;
|
||||
|
||||
const result = await getThreadMessages({
|
||||
currentUser: {
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
},
|
||||
threadId,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to fetch chat messages", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: threadId } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const messageBody = String(body.body ?? "").trim();
|
||||
if (!messageBody) {
|
||||
return NextResponse.json(
|
||||
{ error: "Message body is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const clientMessageIdRaw = body.clientMessageId;
|
||||
const clientMessageId =
|
||||
typeof clientMessageIdRaw === "string" &&
|
||||
clientMessageIdRaw.trim().length > 0
|
||||
? clientMessageIdRaw.trim()
|
||||
: undefined;
|
||||
|
||||
const attachmentsRaw = body.attachments;
|
||||
const attachments = Array.isArray(attachmentsRaw)
|
||||
? attachmentsRaw
|
||||
.filter(
|
||||
(item): item is { url: string; type: string; name?: string } =>
|
||||
!!item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.url === "string" &&
|
||||
typeof item.type === "string",
|
||||
)
|
||||
.map((item) => ({
|
||||
url: item.url,
|
||||
type: item.type,
|
||||
name: typeof item.name === "string" ? item.name : undefined,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const message = await createThreadMessage({
|
||||
currentUser: {
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
},
|
||||
threadId,
|
||||
body: messageBody,
|
||||
clientMessageId,
|
||||
attachments,
|
||||
});
|
||||
|
||||
return NextResponse.json({ message }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to create chat message", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "../route";
|
||||
|
||||
jest.mock("@clerk/nextjs/server", () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/database", () => ({
|
||||
getDatabase: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/sync-user", () => ({
|
||||
ensureUserSynced: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/chat", () => ({
|
||||
markThreadAsRead: jest.fn(),
|
||||
isChatError: jest.fn((error: unknown) =>
|
||||
Boolean(
|
||||
error && typeof error === "object" && "status" in (error as object),
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/logger", () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("/api/chat/threads/[id]/read authz", () => {
|
||||
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
|
||||
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
|
||||
const mockEnsureUserSynced = require("@/lib/sync-user")
|
||||
.ensureUserSynced as jest.Mock;
|
||||
const mockMarkThreadAsRead = require("@/lib/chat")
|
||||
.markThreadAsRead as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetDatabase.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("returns 401 for unauthenticated request", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: null });
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/read",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lastReadMessageId: "msg_1" }),
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when synced user is missing", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/read",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lastReadMessageId: "msg_1" }),
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockMarkThreadAsRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps ChatError to status code", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "user_1",
|
||||
role: "client",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
mockMarkThreadAsRead.mockRejectedValue({
|
||||
status: 403,
|
||||
message: "Forbidden",
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/read",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lastReadMessageId: "msg_1" }),
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns success for authorized user", async () => {
|
||||
mockAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mockEnsureUserSynced.mockResolvedValue({
|
||||
id: "user_1",
|
||||
role: "client",
|
||||
gymId: "gym_1",
|
||||
});
|
||||
mockMarkThreadAsRead.mockResolvedValue(undefined);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/chat/threads/thread_1/read",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lastReadMessageId: "msg_1" }),
|
||||
},
|
||||
);
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ id: "thread_1" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMarkThreadAsRead).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { isChatError, markThreadAsRead } from "@/lib/chat";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: threadId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
const lastReadMessageId =
|
||||
body &&
|
||||
typeof body === "object" &&
|
||||
typeof body.lastReadMessageId === "string"
|
||||
? body.lastReadMessageId
|
||||
: undefined;
|
||||
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await markThreadAsRead({
|
||||
currentUser: {
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
},
|
||||
threadId,
|
||||
lastReadMessageId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to mark chat thread as read", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { isChatError, listThreadsForUser } from "@/lib/chat";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: threadId } = await params;
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const threads = await listThreadsForUser({
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
});
|
||||
|
||||
const thread = threads.find((item) => item.id === threadId);
|
||||
if (!thread) {
|
||||
return NextResponse.json({ error: "Thread not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ thread });
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to fetch chat thread", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import {
|
||||
getOrCreateGymRoomThread,
|
||||
isChatError,
|
||||
listThreadsForUser,
|
||||
} from "@/lib/chat";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const currentUser = await ensureUserSynced(userId, db);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const includeGymRoom =
|
||||
request.nextUrl.searchParams.get("includeGymRoom") !== "false";
|
||||
|
||||
if (includeGymRoom && currentUser.gymId) {
|
||||
await getOrCreateGymRoomThread({
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
});
|
||||
}
|
||||
|
||||
const threads = await listThreadsForUser({
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
gymId: currentUser.gymId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ threads });
|
||||
} catch (error) {
|
||||
if (isChatError(error)) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
log.error("Failed to fetch chat threads", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import axios from "axios";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ChatThread {
|
||||
id: string;
|
||||
type: "gym" | "dm";
|
||||
unreadCount: number;
|
||||
lastMessageBody: string | null;
|
||||
lastMessageAt: string | null;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
threadId: string;
|
||||
senderUserId: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
clientMessageId: string | null;
|
||||
kind?: "text" | "system";
|
||||
attachments?: Array<{ url: string; type: string; name?: string }>;
|
||||
editedAt?: string | null;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
interface ReadEvent {
|
||||
threadId?: string;
|
||||
userId?: string;
|
||||
lastReadMessageId?: string | null;
|
||||
}
|
||||
|
||||
export default function AdminChatPage() {
|
||||
const { getToken, userId } = useAuth();
|
||||
|
||||
const [threads, setThreads] = useState<ChatThread[]>([]);
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [messagesByThreadId, setMessagesByThreadId] = useState<
|
||||
Record<string, ChatMessage[]>
|
||||
>({});
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loadingThreads, setLoadingThreads] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [socketConnected, setSocketConnected] = useState(false);
|
||||
const [typingByThreadId, setTypingByThreadId] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [readByThreadId, setReadByThreadId] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const activeMessages = useMemo(
|
||||
() => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []),
|
||||
[activeThreadId, messagesByThreadId],
|
||||
);
|
||||
|
||||
const typingUsers = useMemo(
|
||||
() => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []),
|
||||
[activeThreadId, typingByThreadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadThreads = async () => {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingThreads(true);
|
||||
const response = await axios.get<{ threads: ChatThread[] }>(
|
||||
"/api/chat/threads",
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
setThreads(response.data.threads);
|
||||
setActiveThreadId(
|
||||
(prev) => prev ?? response.data.threads[0]?.id ?? null,
|
||||
);
|
||||
setReadByThreadId({});
|
||||
} finally {
|
||||
setLoadingThreads(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadThreads();
|
||||
}, [getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadMessages = async () => {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingMessages(true);
|
||||
const response = await axios.get<{ messages: ChatMessage[] }>(
|
||||
`/api/chat/threads/${activeThreadId}/messages`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[activeThreadId]: response.data.messages,
|
||||
}));
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadMessages();
|
||||
}, [activeThreadId, getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const setupSocket = async () => {
|
||||
const token = await getToken();
|
||||
if (!token || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const realtimeBase =
|
||||
process.env.NEXT_PUBLIC_REALTIME_URL ?? window.location.origin;
|
||||
|
||||
const socket = io(realtimeBase, {
|
||||
path: process.env.NEXT_PUBLIC_REALTIME_PATH ?? "/socket.io",
|
||||
transports: ["websocket"],
|
||||
auth: { token },
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => {
|
||||
setSocketConnected(true);
|
||||
if (activeThreadId) {
|
||||
socket.emit("chat:subscribe", { threadId: activeThreadId });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
setSocketConnected(false);
|
||||
setTypingByThreadId({});
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"chat:message:new",
|
||||
(event: { threadId: string; message: ChatMessage }) => {
|
||||
setMessagesByThreadId((prev) => {
|
||||
const current = prev[event.threadId] ?? [];
|
||||
if (current.some((message) => message.id === event.message.id)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[event.threadId]: [...current, event.message],
|
||||
};
|
||||
});
|
||||
|
||||
setThreads((prev) =>
|
||||
prev
|
||||
.map((thread) =>
|
||||
thread.id === event.threadId
|
||||
? {
|
||||
...thread,
|
||||
lastMessageAt: event.message.createdAt,
|
||||
lastMessageBody: event.message.body,
|
||||
unreadCount:
|
||||
event.threadId === activeThreadId ||
|
||||
event.message.senderUserId === userId
|
||||
? thread.unreadCount
|
||||
: thread.unreadCount + 1,
|
||||
}
|
||||
: thread,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aTime = a.lastMessageAt ?? "1970-01-01T00:00:00.000Z";
|
||||
const bTime = b.lastMessageAt ?? "1970-01-01T00:00:00.000Z";
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
socket.on(
|
||||
"chat:message:ack",
|
||||
(event: {
|
||||
threadId: string;
|
||||
message: ChatMessage;
|
||||
clientMessageId?: string | null;
|
||||
}) => {
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[event.threadId]: (prev[event.threadId] ?? []).map((message) =>
|
||||
event.clientMessageId
|
||||
? message.clientMessageId === event.clientMessageId
|
||||
? event.message
|
||||
: message
|
||||
: message.id === event.message.id
|
||||
? event.message
|
||||
: message,
|
||||
),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
socket.on(
|
||||
"chat:typing",
|
||||
(event: { threadId?: string; userId?: string; isTyping?: boolean }) => {
|
||||
if (!event.threadId || !event.userId || event.userId === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTypingByThreadId((prev) => {
|
||||
const current = prev[event.threadId!] ?? [];
|
||||
if (event.isTyping) {
|
||||
if (current.includes(event.userId!)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[event.threadId!]: [...current, event.userId!],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[event.threadId!]: current.filter((id) => id !== event.userId),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
socket.on("chat:read:update", (event: ReadEvent) => {
|
||||
if (!event.threadId || !event.userId || !event.lastReadMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReadByThreadId((prev) => ({
|
||||
...prev,
|
||||
[event.threadId!]: {
|
||||
...(prev[event.threadId!] ?? {}),
|
||||
[event.userId!]: event.lastReadMessageId!,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
socket.on("chat:error", (event: { code?: string; message?: string }) => {
|
||||
if (event.code === "rate_limited") {
|
||||
console.warn(event.message ?? "Chat action rate-limited");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void setupSocket();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeThreadId, getToken, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId || !socketRef.current || !socketConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
socketRef.current.emit("chat:subscribe", { threadId: activeThreadId });
|
||||
setThreads((prev) =>
|
||||
prev.map((thread) =>
|
||||
thread.id === activeThreadId ? { ...thread, unreadCount: 0 } : thread,
|
||||
),
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit("chat:unsubscribe", {
|
||||
threadId: activeThreadId,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [activeThreadId, socketConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = messagesByThreadId[activeThreadId] ?? [];
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || lastMessage.senderUserId === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markRead = async () => {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
await axios.post(
|
||||
`/api/chat/threads/${activeThreadId}/read`,
|
||||
{ lastReadMessageId: lastMessage.id },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (socketRef.current && socketConnected) {
|
||||
socketRef.current.emit("chat:read", {
|
||||
threadId: activeThreadId,
|
||||
lastReadMessageId: lastMessage.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
setReadByThreadId((prev) => ({
|
||||
...prev,
|
||||
[activeThreadId]: {
|
||||
...(prev[activeThreadId] ?? {}),
|
||||
[userId]: lastMessage.id,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
void markRead();
|
||||
}, [activeThreadId, getToken, messagesByThreadId, socketConnected, userId]);
|
||||
|
||||
const onChangeDraft = (value: string) => {
|
||||
setDraft(value);
|
||||
|
||||
if (!activeThreadId || !socketRef.current || !socketConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTyping = value.trim().length > 0;
|
||||
socketRef.current.emit("chat:typing", {
|
||||
threadId: activeThreadId,
|
||||
isTyping,
|
||||
});
|
||||
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
if (activeThreadId && socketRef.current) {
|
||||
socketRef.current.emit("chat:typing", {
|
||||
threadId: activeThreadId,
|
||||
isTyping: false,
|
||||
});
|
||||
}
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const onSend = async () => {
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = draft.trim();
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientMessageId = `admin_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const optimistic: ChatMessage = {
|
||||
id: clientMessageId,
|
||||
threadId: activeThreadId,
|
||||
senderUserId: userId ?? "",
|
||||
body,
|
||||
createdAt: new Date().toISOString(),
|
||||
clientMessageId,
|
||||
kind: "text",
|
||||
attachments: [],
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[activeThreadId]: [...(prev[activeThreadId] ?? []), optimistic],
|
||||
}));
|
||||
setDraft("");
|
||||
|
||||
if (socketRef.current && socketConnected) {
|
||||
socketRef.current.emit("chat:send", {
|
||||
threadId: activeThreadId,
|
||||
body,
|
||||
clientMessageId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post<{ message: ChatMessage }>(
|
||||
`/api/chat/threads/${activeThreadId}/messages`,
|
||||
{ body, clientMessageId },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[activeThreadId]: (prev[activeThreadId] ?? []).map((message) =>
|
||||
message.id === clientMessageId ? response.data.message : message,
|
||||
),
|
||||
}));
|
||||
} catch {
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[activeThreadId]: (prev[activeThreadId] ?? []).filter(
|
||||
(message) => message.id !== clientMessageId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Chat"
|
||||
description="Gym room and private trainer-client messaging"
|
||||
breadcrumbs={[{ label: "Chat" }]}
|
||||
/>
|
||||
|
||||
<div className="text-sm text-slate-500">
|
||||
{socketConnected ? "Realtime connected" : "Realtime disconnected"}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Threads</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{loadingThreads ? (
|
||||
<div className="text-sm text-slate-500">Loading threads...</div>
|
||||
) : (
|
||||
threads.map((thread) => (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => setActiveThreadId(thread.id)}
|
||||
className={`w-full rounded-lg border p-3 text-left transition ${
|
||||
thread.id === activeThreadId
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-slate-200 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-slate-900">
|
||||
{thread.type === "gym" ? "Gym Room" : "Trainer DM"}
|
||||
</p>
|
||||
{thread.unreadCount > 0 && (
|
||||
<span className="rounded-full bg-red-500 px-2 py-0.5 text-xs font-semibold text-white">
|
||||
{thread.unreadCount > 99 ? "99+" : thread.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-slate-500">
|
||||
{thread.lastMessageBody ?? "No messages yet"}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!activeThreadId ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Select a thread to start chatting.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="max-h-[460px] space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
|
||||
{loadingMessages ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Loading messages...
|
||||
</p>
|
||||
) : (
|
||||
activeMessages.map((message) => {
|
||||
const own = message.senderUserId === userId;
|
||||
const threadReads = activeThreadId
|
||||
? (readByThreadId[activeThreadId] ?? {})
|
||||
: {};
|
||||
const seen = Object.entries(threadReads).some(
|
||||
([readerUserId, readMessageId]) =>
|
||||
readerUserId !== userId &&
|
||||
readMessageId === message.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
||||
own
|
||||
? "ml-auto bg-blue-600 text-white"
|
||||
: "bg-slate-100 text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<div>{message.body}</div>
|
||||
{own && seen && (
|
||||
<div className="mt-1 text-[10px] text-blue-100">
|
||||
Seen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{typingUsers.length > 0 && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{typingUsers.length === 1
|
||||
? "Someone is typing..."
|
||||
: `${typingUsers.length} people are typing...`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(event) => onChangeDraft(event.target.value)}
|
||||
placeholder="Type a message"
|
||||
/>
|
||||
<Button onClick={onSend}>Send</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
Activity,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { UserButton, useUser } from "@clerk/nextjs";
|
||||
import { usePendingRecommendationsCount } from "@/hooks/use-api";
|
||||
@@ -52,6 +53,7 @@ export function Sidebar() {
|
||||
href: "/recommendations",
|
||||
badge: pendingCount > 0 ? pendingCount : undefined,
|
||||
},
|
||||
{ icon: MessageCircle, label: "Chat", href: "/chat" },
|
||||
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
|
||||
{ icon: CreditCard, label: "Payments", href: "/payments" },
|
||||
{ icon: Settings, label: "Settings", href: "/settings" },
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
import { db, sql } from "@fitai/database";
|
||||
import type { UserRole } from "@fitai/shared";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export interface ChatUserContext {
|
||||
id: string;
|
||||
role: UserRole;
|
||||
gymId?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatThreadSummary {
|
||||
id: string;
|
||||
type: "gym" | "dm";
|
||||
gymId: string | null;
|
||||
trainerId: string | null;
|
||||
clientId: string | null;
|
||||
createdBy: string;
|
||||
lastMessageAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
archivedAt: Date | null;
|
||||
unreadCount: number;
|
||||
lastMessageBody: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessageRecord {
|
||||
id: string;
|
||||
threadId: string;
|
||||
senderUserId: string;
|
||||
body: string;
|
||||
kind: "text" | "system";
|
||||
attachments: Array<{ url: string; type: string; name?: string }>;
|
||||
clientMessageId: string | null;
|
||||
createdAt: Date;
|
||||
editedAt: Date | null;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
|
||||
class ChatError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
let tablesEnsured = false;
|
||||
|
||||
export async function ensureChatTables(): Promise<void> {
|
||||
if (tablesEnsured) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS chat_threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK (type IN ('gym', 'dm')),
|
||||
gym_id TEXT,
|
||||
trainer_id TEXT,
|
||||
client_id TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
last_message_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
archived_at INTEGER,
|
||||
FOREIGN KEY (gym_id) REFERENCES gyms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (trainer_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (client_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS chat_thread_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role_in_thread TEXT NOT NULL CHECK (role_in_thread IN ('superAdmin', 'admin', 'trainer', 'client')),
|
||||
joined_at INTEGER NOT NULL,
|
||||
left_at INTEGER,
|
||||
muted INTEGER NOT NULL DEFAULT 0,
|
||||
last_read_message_id TEXT,
|
||||
last_read_at INTEGER,
|
||||
FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(thread_id, user_id)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL,
|
||||
sender_user_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('text', 'system')) DEFAULT 'text',
|
||||
attachments_json TEXT,
|
||||
client_message_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
edited_at INTEGER,
|
||||
deleted_at INTEGER,
|
||||
FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (sender_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(sender_user_id, client_message_id)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS chat_message_reads (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
read_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(message_id, user_id)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(
|
||||
sql`CREATE INDEX IF NOT EXISTS chat_threads_gym_type_last_msg_idx ON chat_threads(gym_id, type, last_message_at)`,
|
||||
);
|
||||
await db.run(
|
||||
sql`CREATE INDEX IF NOT EXISTS chat_threads_type_idx ON chat_threads(type)`,
|
||||
);
|
||||
await db.run(
|
||||
sql`CREATE UNIQUE INDEX IF NOT EXISTS chat_threads_trainer_client_type_idx ON chat_threads(trainer_id, client_id, type)`,
|
||||
);
|
||||
await db.run(
|
||||
sql`CREATE INDEX IF NOT EXISTS chat_thread_members_user_thread_idx ON chat_thread_members(user_id, thread_id)`,
|
||||
);
|
||||
await db.run(
|
||||
sql`CREATE INDEX IF NOT EXISTS chat_messages_thread_created_at_idx ON chat_messages(thread_id, created_at)`,
|
||||
);
|
||||
await db.run(
|
||||
sql`CREATE INDEX IF NOT EXISTS chat_message_reads_user_read_at_idx ON chat_message_reads(user_id, read_at)`,
|
||||
);
|
||||
|
||||
tablesEnsured = true;
|
||||
}
|
||||
|
||||
export async function getOrCreateGymRoomThread(
|
||||
currentUser: ChatUserContext,
|
||||
): Promise<ChatThreadSummary> {
|
||||
await ensureChatTables();
|
||||
|
||||
if (!currentUser.gymId) {
|
||||
throw new ChatError("No gym assigned", 400);
|
||||
}
|
||||
|
||||
const rows = await db.all(sql`
|
||||
SELECT
|
||||
id,
|
||||
type,
|
||||
gym_id as gymId,
|
||||
trainer_id as trainerId,
|
||||
client_id as clientId
|
||||
FROM chat_threads
|
||||
WHERE type = 'gym'
|
||||
AND gym_id = ${currentUser.gymId}
|
||||
AND archived_at IS NULL
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
let thread = rows?.[0] as ThreadRow | undefined;
|
||||
|
||||
if (!thread) {
|
||||
const now = nowEpochSeconds();
|
||||
const newThreadId = generateId("thread");
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO chat_threads (
|
||||
id,
|
||||
type,
|
||||
gym_id,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
${newThreadId},
|
||||
${"gym"},
|
||||
${currentUser.gymId},
|
||||
${currentUser.id},
|
||||
${now},
|
||||
${now}
|
||||
)
|
||||
`);
|
||||
|
||||
const createdRows = await db.all(sql`
|
||||
SELECT
|
||||
id,
|
||||
type,
|
||||
gym_id as gymId,
|
||||
trainer_id as trainerId,
|
||||
client_id as clientId
|
||||
FROM chat_threads
|
||||
WHERE id = ${newThreadId}
|
||||
LIMIT 1
|
||||
`);
|
||||
thread = createdRows?.[0] as ThreadRow | undefined;
|
||||
}
|
||||
|
||||
if (!thread) {
|
||||
throw new ChatError("Failed to initialize gym room", 500);
|
||||
}
|
||||
|
||||
await ensureThreadMember(thread.id, currentUser.id, currentUser.role);
|
||||
return await getThreadSummaryForUser(currentUser.id, thread.id);
|
||||
}
|
||||
|
||||
export async function getOrCreateDmThread(params: {
|
||||
trainerId: string;
|
||||
clientId: string;
|
||||
createdBy: ChatUserContext;
|
||||
}): Promise<ChatThreadSummary> {
|
||||
await ensureChatTables();
|
||||
|
||||
const trainerRows = await db.all(sql`
|
||||
SELECT id, role, gym_id as gymId
|
||||
FROM users
|
||||
WHERE id = ${params.trainerId}
|
||||
LIMIT 1
|
||||
`);
|
||||
const clientRows = await db.all(sql`
|
||||
SELECT id, role, gym_id as gymId
|
||||
FROM users
|
||||
WHERE id = ${params.clientId}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const trainer = trainerRows?.[0] as
|
||||
| { id: string; role: UserRole; gymId: string | null }
|
||||
| undefined;
|
||||
const client = clientRows?.[0] as
|
||||
| { id: string; role: UserRole; gymId: string | null }
|
||||
| undefined;
|
||||
|
||||
if (!trainer || !client) {
|
||||
throw new ChatError("Trainer or client not found", 404);
|
||||
}
|
||||
|
||||
if (client.role !== "client") {
|
||||
throw new ChatError("Direct message target must be a client", 400);
|
||||
}
|
||||
|
||||
if (trainer.role !== "trainer" && trainer.role !== "admin") {
|
||||
throw new ChatError("Direct message trainer is invalid", 400);
|
||||
}
|
||||
|
||||
if (!trainer.gymId || trainer.gymId !== client.gymId) {
|
||||
throw new ChatError("Trainer and client must belong to same gym", 400);
|
||||
}
|
||||
|
||||
const assignmentRows = await db.all(sql`
|
||||
SELECT id
|
||||
FROM trainer_client_assignments
|
||||
WHERE trainer_id = ${params.trainerId}
|
||||
AND client_id = ${params.clientId}
|
||||
AND is_active = 1
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (!assignmentRows?.[0]) {
|
||||
throw new ChatError("Trainer is not assigned to this client", 403);
|
||||
}
|
||||
|
||||
const rows = await db.all(sql`
|
||||
SELECT
|
||||
id,
|
||||
type,
|
||||
gym_id as gymId,
|
||||
trainer_id as trainerId,
|
||||
client_id as clientId
|
||||
FROM chat_threads
|
||||
WHERE type = 'dm'
|
||||
AND trainer_id = ${params.trainerId}
|
||||
AND client_id = ${params.clientId}
|
||||
AND archived_at IS NULL
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
let thread = rows?.[0] as ThreadRow | undefined;
|
||||
|
||||
if (!thread) {
|
||||
const now = nowEpochSeconds();
|
||||
const threadId = generateId("thread");
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO chat_threads (
|
||||
id,
|
||||
type,
|
||||
gym_id,
|
||||
trainer_id,
|
||||
client_id,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
${threadId},
|
||||
${"dm"},
|
||||
${trainer.gymId},
|
||||
${params.trainerId},
|
||||
${params.clientId},
|
||||
${params.createdBy.id},
|
||||
${now},
|
||||
${now}
|
||||
)
|
||||
`);
|
||||
|
||||
await ensureThreadMember(threadId, params.trainerId, trainer.role);
|
||||
await ensureThreadMember(threadId, params.clientId, client.role);
|
||||
|
||||
const createdRows = await db.all(sql`
|
||||
SELECT
|
||||
id,
|
||||
type,
|
||||
gym_id as gymId,
|
||||
trainer_id as trainerId,
|
||||
client_id as clientId
|
||||
FROM chat_threads
|
||||
WHERE id = ${threadId}
|
||||
LIMIT 1
|
||||
`);
|
||||
thread = createdRows?.[0] as ThreadRow | undefined;
|
||||
}
|
||||
|
||||
if (!thread) {
|
||||
throw new ChatError("Failed to initialize direct message", 500);
|
||||
}
|
||||
|
||||
await ensureThreadMember(
|
||||
thread.id,
|
||||
params.createdBy.id,
|
||||
params.createdBy.role,
|
||||
);
|
||||
return await getThreadSummaryForUser(params.createdBy.id, thread.id);
|
||||
}
|
||||
|
||||
export async function listThreadsForUser(
|
||||
currentUser: ChatUserContext,
|
||||
): Promise<ChatThreadSummary[]> {
|
||||
await ensureChatTables();
|
||||
|
||||
const rows = await db.all(sql`
|
||||
SELECT
|
||||
t.id,
|
||||
t.type,
|
||||
t.gym_id as gymId,
|
||||
t.trainer_id as trainerId,
|
||||
t.client_id as clientId,
|
||||
t.created_by as createdBy,
|
||||
t.last_message_at as lastMessageAt,
|
||||
t.created_at as createdAt,
|
||||
t.updated_at as updatedAt,
|
||||
t.archived_at as archivedAt,
|
||||
(
|
||||
SELECT m.body
|
||||
FROM chat_messages m
|
||||
WHERE m.thread_id = t.id
|
||||
AND m.deleted_at IS NULL
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 1
|
||||
) as lastMessageBody,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM chat_messages m
|
||||
WHERE m.thread_id = t.id
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.sender_user_id != ${currentUser.id}
|
||||
AND m.created_at > COALESCE(mem.last_read_at, 0)
|
||||
) as unreadCount
|
||||
FROM chat_threads t
|
||||
JOIN chat_thread_members mem
|
||||
ON mem.thread_id = t.id
|
||||
WHERE mem.user_id = ${currentUser.id}
|
||||
AND mem.left_at IS NULL
|
||||
AND t.archived_at IS NULL
|
||||
ORDER BY COALESCE(t.last_message_at, t.created_at) DESC
|
||||
`);
|
||||
|
||||
return (rows as ThreadSummaryRow[]).map(mapThreadSummaryRow);
|
||||
}
|
||||
|
||||
export async function getThreadMessages(params: {
|
||||
currentUser: ChatUserContext;
|
||||
threadId: string;
|
||||
cursor?: number;
|
||||
limit?: number;
|
||||
}): Promise<{ messages: ChatMessageRecord[]; nextCursor: number | null }> {
|
||||
await ensureChatTables();
|
||||
await assertThreadAccess(params.currentUser, params.threadId);
|
||||
|
||||
const pageSize = Math.max(1, Math.min(100, params.limit ?? 30));
|
||||
const cursor = params.cursor;
|
||||
|
||||
const rows = 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 thread_id = ${params.threadId}
|
||||
AND deleted_at IS NULL
|
||||
${cursor ? sql`AND created_at < ${cursor}` : sql``}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${pageSize + 1}
|
||||
`);
|
||||
|
||||
const data = rows as MessageRow[];
|
||||
const hasNextPage = data.length > pageSize;
|
||||
const pageRows = hasNextPage ? data.slice(0, pageSize) : data;
|
||||
const messages = pageRows.map(mapMessageRow).reverse();
|
||||
|
||||
const nextCursor =
|
||||
hasNextPage && pageRows.length > 0
|
||||
? Number(pageRows[pageRows.length - 1].createdAt)
|
||||
: null;
|
||||
|
||||
return { messages, nextCursor };
|
||||
}
|
||||
|
||||
export async function createThreadMessage(params: {
|
||||
currentUser: ChatUserContext;
|
||||
threadId: string;
|
||||
body: string;
|
||||
clientMessageId?: string;
|
||||
attachments?: Array<{ url: string; type: string; name?: string }>;
|
||||
}): Promise<ChatMessageRecord> {
|
||||
await ensureChatTables();
|
||||
await assertThreadAccess(params.currentUser, params.threadId);
|
||||
|
||||
const body = params.body.trim();
|
||||
if (!body) {
|
||||
throw new ChatError("Message body is required", 400);
|
||||
}
|
||||
|
||||
if (body.length > 2000) {
|
||||
throw new ChatError("Message is too long", 400);
|
||||
}
|
||||
|
||||
if (params.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 = ${params.currentUser.id}
|
||||
AND client_message_id = ${params.clientMessageId}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (existingRows?.[0]) {
|
||||
return mapMessageRow(existingRows[0] as MessageRow);
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = generateId("msg");
|
||||
const now = nowEpochSeconds();
|
||||
|
||||
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},
|
||||
${params.threadId},
|
||||
${params.currentUser.id},
|
||||
${body},
|
||||
${"text"},
|
||||
${params.attachments?.length ? JSON.stringify(params.attachments) : null},
|
||||
${params.clientMessageId ?? null},
|
||||
${now}
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(sql`
|
||||
UPDATE chat_threads
|
||||
SET
|
||||
last_message_at = ${now},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${params.threadId}
|
||||
`);
|
||||
|
||||
const messageRows = 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 message = messageRows?.[0] as MessageRow | undefined;
|
||||
if (!message) {
|
||||
throw new ChatError("Failed to send message", 500);
|
||||
}
|
||||
|
||||
return mapMessageRow(message);
|
||||
}
|
||||
|
||||
export async function markThreadAsRead(params: {
|
||||
currentUser: ChatUserContext;
|
||||
threadId: string;
|
||||
lastReadMessageId?: string;
|
||||
}): Promise<void> {
|
||||
await ensureChatTables();
|
||||
await assertThreadAccess(params.currentUser, params.threadId);
|
||||
|
||||
let targetMessageId = params.lastReadMessageId ?? null;
|
||||
|
||||
if (!targetMessageId) {
|
||||
const latestRows = await db.all(sql`
|
||||
SELECT id
|
||||
FROM chat_messages
|
||||
WHERE thread_id = ${params.threadId}
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
const latest = latestRows?.[0] as { id?: string } | undefined;
|
||||
targetMessageId = latest?.id ?? null;
|
||||
}
|
||||
|
||||
const now = nowEpochSeconds();
|
||||
await db.run(sql`
|
||||
UPDATE chat_thread_members
|
||||
SET
|
||||
last_read_message_id = ${targetMessageId},
|
||||
last_read_at = ${now}
|
||||
WHERE thread_id = ${params.threadId}
|
||||
AND user_id = ${params.currentUser.id}
|
||||
`);
|
||||
}
|
||||
|
||||
export function isChatError(error: unknown): error is ChatError {
|
||||
return error instanceof ChatError;
|
||||
}
|
||||
|
||||
async function assertThreadAccess(
|
||||
currentUser: ChatUserContext,
|
||||
threadId: string,
|
||||
): Promise<ThreadRow> {
|
||||
const rows = await db.all(sql`
|
||||
SELECT
|
||||
id,
|
||||
type,
|
||||
gym_id as gymId,
|
||||
trainer_id as trainerId,
|
||||
client_id as clientId
|
||||
FROM chat_threads
|
||||
WHERE id = ${threadId}
|
||||
AND archived_at IS NULL
|
||||
LIMIT 1
|
||||
`);
|
||||
const thread = rows?.[0] as ThreadRow | undefined;
|
||||
|
||||
if (!thread) {
|
||||
throw new ChatError("Thread not found", 404);
|
||||
}
|
||||
|
||||
if (currentUser.role === "superAdmin") {
|
||||
return thread;
|
||||
}
|
||||
|
||||
if (thread.type === "gym") {
|
||||
if (!currentUser.gymId || currentUser.gymId !== thread.gymId) {
|
||||
throw new ChatError("Forbidden", 403);
|
||||
}
|
||||
|
||||
await ensureThreadMember(thread.id, currentUser.id, currentUser.role);
|
||||
return thread;
|
||||
}
|
||||
|
||||
const isParticipant =
|
||||
thread.trainerId === currentUser.id || thread.clientId === currentUser.id;
|
||||
|
||||
if (!isParticipant) {
|
||||
throw new ChatError("Forbidden", 403);
|
||||
}
|
||||
|
||||
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]) {
|
||||
throw new ChatError("Trainer-client assignment is not active", 403);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureThreadMember(thread.id, currentUser.id, currentUser.role);
|
||||
return thread;
|
||||
}
|
||||
|
||||
async function getThreadSummaryForUser(
|
||||
userId: string,
|
||||
threadId: string,
|
||||
): Promise<ChatThreadSummary> {
|
||||
const rows = await db.all(sql`
|
||||
SELECT
|
||||
t.id,
|
||||
t.type,
|
||||
t.gym_id as gymId,
|
||||
t.trainer_id as trainerId,
|
||||
t.client_id as clientId,
|
||||
t.created_by as createdBy,
|
||||
t.last_message_at as lastMessageAt,
|
||||
t.created_at as createdAt,
|
||||
t.updated_at as updatedAt,
|
||||
t.archived_at as archivedAt,
|
||||
(
|
||||
SELECT m.body
|
||||
FROM chat_messages m
|
||||
WHERE m.thread_id = t.id
|
||||
AND m.deleted_at IS NULL
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 1
|
||||
) as lastMessageBody,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM chat_messages m
|
||||
WHERE m.thread_id = t.id
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.sender_user_id != ${userId}
|
||||
AND m.created_at > COALESCE(mem.last_read_at, 0)
|
||||
) as unreadCount
|
||||
FROM chat_threads t
|
||||
JOIN chat_thread_members mem
|
||||
ON mem.thread_id = t.id
|
||||
AND mem.user_id = ${userId}
|
||||
AND mem.left_at IS NULL
|
||||
WHERE t.id = ${threadId}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const row = rows?.[0] as ThreadSummaryRow | undefined;
|
||||
if (!row) {
|
||||
throw new ChatError("Thread not found", 404);
|
||||
}
|
||||
|
||||
return mapThreadSummaryRow(row);
|
||||
}
|
||||
|
||||
async function ensureThreadMember(
|
||||
threadId: string,
|
||||
userId: string,
|
||||
role: UserRole,
|
||||
): 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 mapThreadSummaryRow(row: ThreadSummaryRow): ChatThreadSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
gymId: row.gymId ?? null,
|
||||
trainerId: row.trainerId ?? null,
|
||||
clientId: row.clientId ?? null,
|
||||
createdBy: row.createdBy,
|
||||
lastMessageAt: toDate(row.lastMessageAt),
|
||||
createdAt: toDate(row.createdAt) ?? new Date(),
|
||||
updatedAt: toDate(row.updatedAt) ?? new Date(),
|
||||
archivedAt: toDate(row.archivedAt),
|
||||
unreadCount: Number(row.unreadCount ?? 0),
|
||||
lastMessageBody: row.lastMessageBody ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMessageRow(row: MessageRow): ChatMessageRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
threadId: row.threadId,
|
||||
senderUserId: row.senderUserId,
|
||||
body: row.body,
|
||||
kind: row.kind,
|
||||
attachments: parseAttachments(row.attachmentsJson),
|
||||
clientMessageId: row.clientMessageId ?? null,
|
||||
createdAt: toDate(row.createdAt) ?? new Date(),
|
||||
editedAt: toDate(row.editedAt),
|
||||
deletedAt: toDate(row.deletedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttachments(
|
||||
value: string | null,
|
||||
): Array<{ url: string; type: string; name?: string }> {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter(
|
||||
(item): item is { url: string; type: string; name?: string } =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.url === "string" &&
|
||||
typeof item.type === "string",
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn("Failed to parse chat attachments JSON", { value, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toDate(value: number | string | null | undefined): Date | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(numberValue * 1000);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
interface ThreadRow {
|
||||
id: string;
|
||||
type: "gym" | "dm";
|
||||
gymId: string | null;
|
||||
trainerId: string | null;
|
||||
clientId: string | null;
|
||||
}
|
||||
|
||||
interface ThreadSummaryRow {
|
||||
id: string;
|
||||
type: "gym" | "dm";
|
||||
gymId: string | null;
|
||||
trainerId: string | null;
|
||||
clientId: string | null;
|
||||
createdBy: string;
|
||||
lastMessageAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
archivedAt: number | null;
|
||||
unreadCount: number;
|
||||
lastMessageBody: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user