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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user