Task #511: Fully remove Chat feature
Destructive removal per user confirmation ("حذف نهائي ما يرجع").
Removed:
- API: routes/conversations.ts, schema/conversations.ts, all chat
socket handlers in src/index.ts, /admin/users/:id/dependents/
conversations+messages endpoints, conversation/message dependency
counts in users/stats routes.
- Web: pages/chat.tsx, /chat route, dock chat filter, MessageSquare
icon and messages StatCard on home, all chat-related UI in
notifications + admin (dependency badges, delete-dialog rows,
UserDependentConversations/Messages sections, count map keys).
- Locales: nav.chat, home.stats.messages, full chat.* block,
admin.deleteUser conv/msgCount, admin.users.counts.conv/msg,
admin.audit.unit.conversation_*/message_*, admin.dependents.user*.
- OpenAPI spec: tags, all /conversations/* paths, conv/msg dependent
paths, related schemas (ConversationWithDetails, MessageWithSender,
UserDependentConversation/MessageItem+Page, etc.), UserProfile and
UserDeletionConflict conv/msg fields, HomeStats.unreadMessages.
Regenerated client via orval.
- Database: dropped message_reads, messages,
conversation_participants, conversations (CASCADE); deleted
notifications with related_type='conversation' or type='chat';
deleted apps row with slug='chat'; ran drizzle push-force.
- Seed: removed chat:access permission + user-role assignment +
seeded chat app entry from scripts/src/seed.ts.
- Tests: deleted conversations-leave.test.mjs; cleaned chat refs from
list-dependency-counts, delete-force-warnings, audit-log-coverage,
and admin-inline-dependency-counts (e2e) — replaced chat dependents
with note dependents where needed for force-delete coverage.
Notes preserved: notes.tsx noConversationsYet/conversationWith refer
to NOTE THREADS (not chat) and were intentionally NOT touched.
executive-meetings.ts not modified per replit.md restriction.
Pre-existing flaky test failures in executive-meetings/group/etc
suites remain unrelated to this task.
This commit is contained in:
@@ -3,11 +3,6 @@ import { Server as SocketIOServer } from "socket.io";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import app, { sessionMiddleware } from "./app";
|
||||
import { logger } from "./lib/logger";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
conversationParticipantsTable,
|
||||
} from "@workspace/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
const rawPort = process.env["PORT"];
|
||||
|
||||
@@ -58,29 +53,6 @@ io.on("connection", (socket) => {
|
||||
|
||||
socket.join(`user:${userId}`);
|
||||
|
||||
socket.on(
|
||||
"join_conversation",
|
||||
async (conversationId: number) => {
|
||||
const [participant] = await db
|
||||
.select()
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (participant) {
|
||||
socket.join(`conversation:${conversationId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.on("leave_conversation", (conversationId: number) => {
|
||||
socket.leave(`conversation:${conversationId}`);
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
logger.info({ socketId: socket.id }, "Socket disconnected");
|
||||
});
|
||||
|
||||
@@ -1,981 +0,0 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and, desc, asc, sql } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
conversationsTable,
|
||||
conversationParticipantsTable,
|
||||
messagesTable,
|
||||
messageReadsTable,
|
||||
notificationsTable,
|
||||
usersTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth } from "../middlewares/auth";
|
||||
import {
|
||||
CreateConversationBody,
|
||||
GetConversationParams,
|
||||
ListMessagesParams,
|
||||
SendMessageParams,
|
||||
SendMessageBody,
|
||||
MarkConversationReadParams,
|
||||
UpdateConversationBody,
|
||||
UpdateConversationParams,
|
||||
AddConversationParticipantsParams,
|
||||
AddConversationParticipantsBody,
|
||||
RemoveConversationParticipantParams,
|
||||
UpdateConversationStateParams,
|
||||
UpdateConversationStateBody,
|
||||
LeaveConversationBody,
|
||||
LeaveConversationParams,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
async function isConversationMember(
|
||||
conversationId: number,
|
||||
userId: number,
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ userId: conversationParticipantsTable.userId })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function getParticipantState(
|
||||
conversationId: number,
|
||||
userId: number,
|
||||
): Promise<{ isMuted: boolean; isArchived: boolean } | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
isMuted: conversationParticipantsTable.isMuted,
|
||||
isArchived: conversationParticipantsTable.isArchived,
|
||||
})
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function buildConversationDetails(conversationId: number, currentUserId: number) {
|
||||
const [conv] = await db
|
||||
.select()
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, conversationId));
|
||||
|
||||
if (!conv) return null;
|
||||
|
||||
const participants = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
isAdmin: conversationParticipantsTable.isAdmin,
|
||||
})
|
||||
.from(conversationParticipantsTable)
|
||||
.innerJoin(usersTable, eq(conversationParticipantsTable.userId, usersTable.id))
|
||||
.where(eq(conversationParticipantsTable.conversationId, conversationId));
|
||||
|
||||
const lastMessages = await db
|
||||
.select()
|
||||
.from(messagesTable)
|
||||
.where(eq(messagesTable.conversationId, conversationId))
|
||||
.orderBy(desc(messagesTable.createdAt))
|
||||
.limit(1);
|
||||
|
||||
let lastMessage = null;
|
||||
if (lastMessages.length > 0) {
|
||||
const msg = lastMessages[0];
|
||||
const [sender] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
isAdmin: sql<boolean>`false`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, msg.senderId));
|
||||
|
||||
lastMessage = { ...msg, sender };
|
||||
}
|
||||
|
||||
const unreadResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(messagesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(messagesTable.conversationId, conversationId),
|
||||
eq(messagesTable.kind, "user"),
|
||||
sql`${messagesTable.id} NOT IN (
|
||||
SELECT message_id FROM message_reads WHERE user_id = ${currentUserId}
|
||||
)`,
|
||||
sql`${messagesTable.senderId} != ${currentUserId}`,
|
||||
),
|
||||
);
|
||||
|
||||
const unreadCount = Number(unreadResult[0]?.count ?? 0);
|
||||
|
||||
const state = await getParticipantState(conversationId, currentUserId);
|
||||
|
||||
return {
|
||||
...conv,
|
||||
participants,
|
||||
lastMessage,
|
||||
unreadCount,
|
||||
isMuted: state?.isMuted ?? false,
|
||||
isArchived: state?.isArchived ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/conversations", requireAuth, async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
|
||||
const userConvs = await db
|
||||
.select({ conversationId: conversationParticipantsTable.conversationId })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(eq(conversationParticipantsTable.userId, userId));
|
||||
|
||||
const results = await Promise.all(
|
||||
userConvs.map((c) => buildConversationDetails(c.conversationId, userId)),
|
||||
);
|
||||
|
||||
res.json(results.filter(Boolean));
|
||||
});
|
||||
|
||||
router.post("/conversations", requireAuth, async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const parsed = CreateConversationBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const { participantIds, nameAr, nameEn, isGroup, avatarUrl } = parsed.data;
|
||||
|
||||
const allParticipants = [...new Set([userId, ...participantIds])];
|
||||
|
||||
const [conv] = await db
|
||||
.insert(conversationsTable)
|
||||
.values({
|
||||
nameAr: nameAr ?? null,
|
||||
nameEn: nameEn ?? null,
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
isGroup: isGroup ?? false,
|
||||
createdBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(conversationParticipantsTable).values(
|
||||
allParticipants.map((pid) => ({
|
||||
conversationId: conv.id,
|
||||
userId: pid,
|
||||
isAdmin: pid === userId,
|
||||
})),
|
||||
);
|
||||
|
||||
const details = await buildConversationDetails(conv.id, userId);
|
||||
res.status(201).json(details);
|
||||
});
|
||||
|
||||
router.get("/conversations/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = GetConversationParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session.userId!;
|
||||
|
||||
if (!(await isConversationMember(params.data.id, userId))) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(details);
|
||||
});
|
||||
|
||||
router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = UpdateConversationParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateConversationBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session.userId!;
|
||||
|
||||
const [participant] = await db
|
||||
.select({ isAdmin: conversationParticipantsTable.isAdmin })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!participant) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
if (!participant.isAdmin) {
|
||||
res.status(403).json({ error: "Only group admins can update this conversation" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [conv] = await db
|
||||
.select({
|
||||
isGroup: conversationsTable.isGroup,
|
||||
nameAr: conversationsTable.nameAr,
|
||||
nameEn: conversationsTable.nameEn,
|
||||
})
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, params.data.id));
|
||||
if (!conv) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
if (!conv.isGroup) {
|
||||
res.status(400).json({ error: "Direct conversations cannot be updated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<typeof conversationsTable.$inferInsert> = {};
|
||||
if (parsed.data.avatarUrl !== undefined) {
|
||||
updates.avatarUrl = parsed.data.avatarUrl;
|
||||
}
|
||||
if (parsed.data.nameAr !== undefined) {
|
||||
const trimmed = parsed.data.nameAr?.trim();
|
||||
updates.nameAr = trimmed ? trimmed : null;
|
||||
}
|
||||
if (parsed.data.nameEn !== undefined) {
|
||||
const trimmed = parsed.data.nameEn?.trim();
|
||||
updates.nameEn = trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
const finalNameAr = updates.nameAr !== undefined ? updates.nameAr : conv.nameAr;
|
||||
const finalNameEn = updates.nameEn !== undefined ? updates.nameEn : conv.nameEn;
|
||||
if (
|
||||
(updates.nameAr !== undefined || updates.nameEn !== undefined) &&
|
||||
!finalNameAr &&
|
||||
!finalNameEn
|
||||
) {
|
||||
res.status(400).json({ error: "Group must have an Arabic or English name" });
|
||||
return;
|
||||
}
|
||||
|
||||
const nameChanged =
|
||||
(updates.nameAr !== undefined && updates.nameAr !== conv.nameAr) ||
|
||||
(updates.nameEn !== undefined && updates.nameEn !== conv.nameEn);
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db
|
||||
.update(conversationsTable)
|
||||
.set(updates)
|
||||
.where(eq(conversationsTable.id, params.data.id));
|
||||
}
|
||||
|
||||
if (nameChanged) {
|
||||
const actor = await getUserDisplay(userId);
|
||||
await insertAndEmitSystemMessage(params.data.id, userId, "group_renamed", {
|
||||
actor,
|
||||
oldNameAr: conv.nameAr,
|
||||
oldNameEn: conv.nameEn,
|
||||
newNameAr: finalNameAr,
|
||||
newNameEn: finalNameEn,
|
||||
});
|
||||
}
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await emitConversationUpdate(params.data.id);
|
||||
|
||||
res.json(details);
|
||||
});
|
||||
|
||||
async function evictUserFromConversationRoom(
|
||||
userId: number,
|
||||
conversationId: number,
|
||||
): Promise<void> {
|
||||
const { io } = await import("../index.js");
|
||||
const sockets = await io.in(`user:${userId}`).fetchSockets();
|
||||
for (const s of sockets) {
|
||||
s.leave(`conversation:${conversationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
type SystemMessageMeta = Record<string, unknown>;
|
||||
|
||||
async function getUserDisplay(userId: number): Promise<{
|
||||
id: number;
|
||||
username: string;
|
||||
displayNameAr: string | null;
|
||||
displayNameEn: string | null;
|
||||
} | null> {
|
||||
const [u] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId));
|
||||
return u ?? null;
|
||||
}
|
||||
|
||||
async function getUsersDisplay(userIds: number[]): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
displayNameAr: string | null;
|
||||
displayNameEn: string | null;
|
||||
}>
|
||||
> {
|
||||
if (userIds.length === 0) return [];
|
||||
return db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.id} IN (${sql.join(userIds.map((id) => sql`${id}`), sql`, `)})`);
|
||||
}
|
||||
|
||||
async function insertAndEmitSystemMessage(
|
||||
conversationId: number,
|
||||
actorId: number,
|
||||
kind: string,
|
||||
meta: SystemMessageMeta,
|
||||
): Promise<void> {
|
||||
const [msg] = await db
|
||||
.insert(messagesTable)
|
||||
.values({
|
||||
conversationId,
|
||||
senderId: actorId,
|
||||
content: "",
|
||||
kind,
|
||||
meta,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [sender] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
isAdmin: sql<boolean>`false`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, actorId));
|
||||
|
||||
const fullMessage = { ...msg, sender };
|
||||
const { io } = await import("../index.js");
|
||||
io.to(`conversation:${conversationId}`).emit("new_message", fullMessage);
|
||||
}
|
||||
|
||||
async function emitConversationUpdate(conversationId: number): Promise<void> {
|
||||
const { io } = await import("../index.js");
|
||||
const members = await db
|
||||
.select({ userId: conversationParticipantsTable.userId })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(eq(conversationParticipantsTable.conversationId, conversationId));
|
||||
|
||||
io.to(`conversation:${conversationId}`).emit("conversation_updated", {
|
||||
conversationId,
|
||||
});
|
||||
for (const m of members) {
|
||||
io.to(`user:${m.userId}`).emit("conversation_updated", { conversationId });
|
||||
}
|
||||
}
|
||||
|
||||
async function requireGroupAdmin(
|
||||
conversationId: number,
|
||||
userId: number,
|
||||
): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
||||
const [participant] = await db
|
||||
.select({ isAdmin: conversationParticipantsTable.isAdmin })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
if (!participant) return { ok: false, status: 403, error: "Forbidden" };
|
||||
if (!participant.isAdmin) {
|
||||
return { ok: false, status: 403, error: "Only group admins can manage participants" };
|
||||
}
|
||||
const [conv] = await db
|
||||
.select({ isGroup: conversationsTable.isGroup })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, conversationId));
|
||||
if (!conv) return { ok: false, status: 404, error: "Conversation not found" };
|
||||
if (!conv.isGroup) {
|
||||
return { ok: false, status: 400, error: "Direct conversations cannot be modified" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/conversations/:id/participants",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const params = AddConversationParticipantsParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const parsed = AddConversationParticipantsBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
const guard = await requireGroupAdmin(params.data.id, userId);
|
||||
if (!guard.ok) {
|
||||
res.status(guard.status).json({ error: guard.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedIds = [...new Set(parsed.data.userIds)].filter((id) => Number.isInteger(id));
|
||||
if (requestedIds.length === 0) {
|
||||
res.status(400).json({ error: "No users to add" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ userId: conversationParticipantsTable.userId })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(eq(conversationParticipantsTable.conversationId, params.data.id));
|
||||
const existingIds = new Set(existing.map((e) => e.userId));
|
||||
const toAdd = requestedIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
const validUsers = await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.id} IN (${sql.join(toAdd.map((id) => sql`${id}`), sql`, `)})`);
|
||||
const validIds = validUsers.map((u) => u.id);
|
||||
if (validIds.length === 0) {
|
||||
res.status(400).json({ error: "No valid users to add" });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(conversationParticipantsTable)
|
||||
.values(
|
||||
validIds.map((pid) => ({
|
||||
conversationId: params.data.id,
|
||||
userId: pid,
|
||||
isAdmin: false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
|
||||
const [actor, addedUsers] = await Promise.all([
|
||||
getUserDisplay(userId),
|
||||
getUsersDisplay(validIds),
|
||||
]);
|
||||
await insertAndEmitSystemMessage(params.data.id, userId, "members_added", {
|
||||
actor,
|
||||
addedUsers,
|
||||
});
|
||||
}
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
await emitConversationUpdate(params.data.id);
|
||||
res.json(details);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/conversations/:id/participants/:userId",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const params = RemoveConversationParticipantParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
const guard = await requireGroupAdmin(params.data.id, userId);
|
||||
if (!guard.ok) {
|
||||
res.status(guard.status).json({ error: guard.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.data.userId === userId) {
|
||||
res.status(400).json({ error: "Admins cannot remove themselves" });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await db
|
||||
.delete(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.userId, params.data.userId),
|
||||
),
|
||||
)
|
||||
.returning({ userId: conversationParticipantsTable.userId });
|
||||
|
||||
await evictUserFromConversationRoom(params.data.userId, params.data.id);
|
||||
|
||||
if (deleted.length > 0) {
|
||||
const [actor, removedUser] = await Promise.all([
|
||||
getUserDisplay(userId),
|
||||
getUserDisplay(params.data.userId),
|
||||
]);
|
||||
await insertAndEmitSystemMessage(params.data.id, userId, "member_removed", {
|
||||
actor,
|
||||
removedUser,
|
||||
});
|
||||
}
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
await emitConversationUpdate(params.data.id);
|
||||
res.json(details);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/conversations/:id/state",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const params = UpdateConversationStateParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateConversationStateBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
|
||||
if (!(await isConversationMember(params.data.id, userId))) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: { isMuted?: boolean; isArchived?: boolean } = {};
|
||||
if (parsed.data.isMuted !== undefined) updates.isMuted = parsed.data.isMuted;
|
||||
if (parsed.data.isArchived !== undefined) updates.isArchived = parsed.data.isArchived;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db
|
||||
.update(conversationParticipantsTable)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
res.json(details);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/conversations/:id/leave",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const params = LeaveConversationParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const body = LeaveConversationBody.safeParse(req.body ?? {});
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error.message });
|
||||
return;
|
||||
}
|
||||
const requestedSuccessorId = body.data.successorId ?? null;
|
||||
const userId = req.session.userId!;
|
||||
|
||||
const [conv] = await db
|
||||
.select({ isGroup: conversationsTable.isGroup })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, params.data.id));
|
||||
if (!conv) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
if (!conv.isGroup) {
|
||||
res.status(400).json({ error: "Cannot leave a direct conversation" });
|
||||
return;
|
||||
}
|
||||
|
||||
const participants = await db
|
||||
.select({
|
||||
userId: conversationParticipantsTable.userId,
|
||||
isAdmin: conversationParticipantsTable.isAdmin,
|
||||
joinedAt: conversationParticipantsTable.joinedAt,
|
||||
})
|
||||
.from(conversationParticipantsTable)
|
||||
.where(eq(conversationParticipantsTable.conversationId, params.data.id))
|
||||
.orderBy(asc(conversationParticipantsTable.joinedAt));
|
||||
|
||||
const leaver = participants.find((p) => p.userId === userId);
|
||||
if (!leaver) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = participants.filter((p) => p.userId !== userId);
|
||||
|
||||
// If leaver was the only participant, clean up the conversation entirely.
|
||||
if (remaining.length === 0) {
|
||||
await db
|
||||
.delete(conversationsTable)
|
||||
.where(eq(conversationsTable.id, params.data.id));
|
||||
await evictUserFromConversationRoom(userId, params.data.id);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if we need to promote a successor admin.
|
||||
const leaverWasOnlyAdmin =
|
||||
leaver.isAdmin && !remaining.some((p) => p.isAdmin);
|
||||
let successor: (typeof remaining)[number] | null = null;
|
||||
if (leaverWasOnlyAdmin) {
|
||||
if (requestedSuccessorId !== null) {
|
||||
const chosen = remaining.find(
|
||||
(p) => p.userId === requestedSuccessorId,
|
||||
);
|
||||
if (!chosen) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Chosen successor is not a member of this group" });
|
||||
return;
|
||||
}
|
||||
successor = chosen;
|
||||
} else {
|
||||
successor = remaining[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (successor) {
|
||||
await db
|
||||
.update(conversationParticipantsTable)
|
||||
.set({ isAdmin: true })
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.userId, successor.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Announce the leave (and promotion) before removing the leaver so the
|
||||
// system message has a sensible actor and members see it in real time.
|
||||
const actor = await getUserDisplay(userId);
|
||||
await insertAndEmitSystemMessage(params.data.id, userId, "member_left", {
|
||||
actor,
|
||||
});
|
||||
|
||||
if (successor) {
|
||||
const promoted = await getUserDisplay(successor.userId);
|
||||
await insertAndEmitSystemMessage(
|
||||
params.data.id,
|
||||
successor.userId,
|
||||
"admin_promoted",
|
||||
{ promoted, reason: "previous_admin_left" },
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await evictUserFromConversationRoom(userId, params.data.id);
|
||||
await emitConversationUpdate(params.data.id);
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/conversations/:id/messages", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = ListMessagesParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session.userId!;
|
||||
|
||||
if (!(await isConversationMember(params.data.id, userId))) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const msgs = await db
|
||||
.select()
|
||||
.from(messagesTable)
|
||||
.where(eq(messagesTable.conversationId, params.data.id))
|
||||
.orderBy(desc(messagesTable.createdAt))
|
||||
.limit(50);
|
||||
|
||||
const messagesWithSenders = await Promise.all(
|
||||
msgs.reverse().map(async (msg) => {
|
||||
const [sender] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
isAdmin: sql<boolean>`false`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, msg.senderId));
|
||||
|
||||
return { ...msg, sender };
|
||||
}),
|
||||
);
|
||||
|
||||
res.json(messagesWithSenders);
|
||||
});
|
||||
|
||||
router.post("/conversations/:id/messages", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = SendMessageParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = SendMessageBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session.userId!;
|
||||
|
||||
if (!(await isConversationMember(params.data.id, userId))) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [msg] = await db
|
||||
.insert(messagesTable)
|
||||
.values({
|
||||
conversationId: params.data.id,
|
||||
senderId: userId,
|
||||
content: parsed.data.content,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Auto-unarchive for all participants when a new message arrives
|
||||
await db
|
||||
.update(conversationParticipantsTable)
|
||||
.set({ isArchived: false })
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.isArchived, true),
|
||||
),
|
||||
);
|
||||
|
||||
const [sender] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
isAdmin: sql<boolean>`false`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId));
|
||||
|
||||
const fullMessage = { ...msg, sender };
|
||||
|
||||
const { io } = await import("../index.js");
|
||||
io.to(`conversation:${params.data.id}`).emit("new_message", fullMessage);
|
||||
|
||||
await createMessageNotifications({
|
||||
conversationId: params.data.id,
|
||||
senderId: userId,
|
||||
sender,
|
||||
content: parsed.data.content,
|
||||
});
|
||||
|
||||
res.status(201).json(fullMessage);
|
||||
});
|
||||
|
||||
async function createMessageNotifications(input: {
|
||||
conversationId: number;
|
||||
senderId: number;
|
||||
sender: {
|
||||
displayNameAr: string | null;
|
||||
displayNameEn: string | null;
|
||||
username: string;
|
||||
};
|
||||
content: string;
|
||||
}): Promise<void> {
|
||||
const recipients = await db
|
||||
.select({ userId: conversationParticipantsTable.userId })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, input.conversationId),
|
||||
eq(conversationParticipantsTable.isMuted, false),
|
||||
sql`${conversationParticipantsTable.userId} != ${input.senderId}`,
|
||||
),
|
||||
);
|
||||
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const [conv] = await db
|
||||
.select({
|
||||
isGroup: conversationsTable.isGroup,
|
||||
nameAr: conversationsTable.nameAr,
|
||||
nameEn: conversationsTable.nameEn,
|
||||
})
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, input.conversationId));
|
||||
if (!conv) return;
|
||||
|
||||
const senderNameAr =
|
||||
input.sender.displayNameAr || input.sender.displayNameEn || input.sender.username;
|
||||
const senderNameEn =
|
||||
input.sender.displayNameEn || input.sender.displayNameAr || input.sender.username;
|
||||
|
||||
const preview = input.content.trim().slice(0, 140);
|
||||
|
||||
let titleAr: string;
|
||||
let titleEn: string;
|
||||
let bodyAr: string | null;
|
||||
let bodyEn: string | null;
|
||||
|
||||
if (conv.isGroup) {
|
||||
const groupNameAr = conv.nameAr || conv.nameEn || senderNameAr;
|
||||
const groupNameEn = conv.nameEn || conv.nameAr || senderNameEn;
|
||||
titleAr = groupNameAr;
|
||||
titleEn = groupNameEn;
|
||||
bodyAr = preview ? `${senderNameAr}: ${preview}` : senderNameAr;
|
||||
bodyEn = preview ? `${senderNameEn}: ${preview}` : senderNameEn;
|
||||
} else {
|
||||
titleAr = senderNameAr;
|
||||
titleEn = senderNameEn;
|
||||
bodyAr = preview || null;
|
||||
bodyEn = preview || null;
|
||||
}
|
||||
|
||||
await db.insert(notificationsTable).values(
|
||||
recipients.map((r) => ({
|
||||
userId: r.userId,
|
||||
titleAr,
|
||||
titleEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
type: "chat",
|
||||
relatedId: input.conversationId,
|
||||
relatedType: "conversation",
|
||||
})),
|
||||
);
|
||||
|
||||
const { io } = await import("../index.js");
|
||||
for (const r of recipients) {
|
||||
io.to(`user:${r.userId}`).emit("notification_created", {
|
||||
type: "chat",
|
||||
relatedId: input.conversationId,
|
||||
relatedType: "conversation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/conversations/:id/read", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = MarkConversationReadParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.session.userId!;
|
||||
|
||||
if (!(await isConversationMember(params.data.id, userId))) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const unreadMessages = await db
|
||||
.select({ id: messagesTable.id })
|
||||
.from(messagesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(messagesTable.conversationId, params.data.id),
|
||||
sql`${messagesTable.id} NOT IN (
|
||||
SELECT message_id FROM message_reads WHERE user_id = ${userId}
|
||||
)`,
|
||||
),
|
||||
);
|
||||
|
||||
if (unreadMessages.length > 0) {
|
||||
await db.insert(messageReadsTable).values(
|
||||
unreadMessages.map((m) => ({ messageId: m.id, userId })),
|
||||
).onConflictDoNothing();
|
||||
|
||||
const { io } = await import("../index.js");
|
||||
io.to(`conversation:${params.data.id}`).emit("messages_read", {
|
||||
conversationId: params.data.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,7 +4,6 @@ import authRouter from "./auth";
|
||||
import appsRouter from "./apps";
|
||||
import servicesRouter from "./services";
|
||||
import serviceOrdersRouter from "./service-orders";
|
||||
import conversationsRouter from "./conversations";
|
||||
import notificationsRouter from "./notifications";
|
||||
import usersRouter from "./users";
|
||||
import statsRouter from "./stats";
|
||||
@@ -23,7 +22,6 @@ router.use(authRouter);
|
||||
router.use(appsRouter);
|
||||
router.use(servicesRouter);
|
||||
router.use(serviceOrdersRouter);
|
||||
router.use(conversationsRouter);
|
||||
router.use(notificationsRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(statsRouter);
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
servicesTable,
|
||||
notificationsTable,
|
||||
usersTable,
|
||||
messagesTable,
|
||||
messageReadsTable,
|
||||
appOpensTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||
@@ -91,23 +89,10 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable);
|
||||
|
||||
const [unreadMsgCount] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(messagesTable)
|
||||
.where(
|
||||
and(
|
||||
sql`${messagesTable.id} NOT IN (
|
||||
SELECT message_id FROM message_reads WHERE user_id = ${userId}
|
||||
)`,
|
||||
sql`${messagesTable.senderId} != ${userId}`,
|
||||
),
|
||||
);
|
||||
|
||||
res.json({
|
||||
totalApps: Number(appsCount?.count ?? 0),
|
||||
totalServices: Number(servicesCount?.count ?? 0),
|
||||
unreadNotifications: Number(unreadNotifCount?.count ?? 0),
|
||||
unreadMessages: Number(unreadMsgCount?.count ?? 0),
|
||||
totalUsers: Number(usersCount?.count ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
notesTable,
|
||||
serviceOrdersTable,
|
||||
servicesTable,
|
||||
conversationsTable,
|
||||
messagesTable,
|
||||
auditLogsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||
@@ -74,8 +72,6 @@ export async function getGroupsForUser(userId: number): Promise<GroupSummary[]>
|
||||
type DependencyCounts = {
|
||||
noteCount: number;
|
||||
orderCount: number;
|
||||
conversationCount: number;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
function buildUserProfile(
|
||||
@@ -293,27 +289,9 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
.from(serviceOrdersTable)
|
||||
.where(inArray(serviceOrdersTable.userId, userIds))
|
||||
.groupBy(serviceOrdersTable.userId);
|
||||
const convRows = await db
|
||||
.select({
|
||||
userId: conversationsTable.createdBy,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(conversationsTable)
|
||||
.where(inArray(conversationsTable.createdBy, userIds))
|
||||
.groupBy(conversationsTable.createdBy);
|
||||
const msgRows = await db
|
||||
.select({
|
||||
userId: messagesTable.senderId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(messagesTable)
|
||||
.where(inArray(messagesTable.senderId, userIds))
|
||||
.groupBy(messagesTable.senderId);
|
||||
|
||||
const noteMap = new Map(noteRows.map((r) => [r.userId, r.count]));
|
||||
const orderMap = new Map(orderRows.map((r) => [r.userId, r.count]));
|
||||
const convMap = new Map(convRows.map((r) => [r.userId, r.count]));
|
||||
const msgMap = new Map(msgRows.map((r) => [r.userId, r.count]));
|
||||
|
||||
res.json(
|
||||
users.map((u) =>
|
||||
@@ -324,8 +302,6 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
{
|
||||
noteCount: noteMap.get(u.id) ?? 0,
|
||||
orderCount: orderMap.get(u.id) ?? 0,
|
||||
conversationCount: convMap.get(u.id) ?? 0,
|
||||
messageCount: msgMap.get(u.id) ?? 0,
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -481,87 +457,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/users/:id/dependents/conversations",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureUserExists(id))) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseUserDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, id));
|
||||
const items = await db
|
||||
.select({
|
||||
id: conversationsTable.id,
|
||||
nameAr: conversationsTable.nameAr,
|
||||
nameEn: conversationsTable.nameEn,
|
||||
isGroup: conversationsTable.isGroup,
|
||||
createdAt: conversationsTable.createdAt,
|
||||
})
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, id))
|
||||
.orderBy(sql`${conversationsTable.updatedAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/users/:id/dependents/messages",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureUserExists(id))) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseUserDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(messagesTable)
|
||||
.where(eq(messagesTable.senderId, id));
|
||||
// Join conversation context so the popover can show *where* the user
|
||||
// talked, not just an opaque list of message IDs.
|
||||
const items = await db
|
||||
.select({
|
||||
id: messagesTable.id,
|
||||
content: messagesTable.content,
|
||||
kind: messagesTable.kind,
|
||||
createdAt: messagesTable.createdAt,
|
||||
conversationId: conversationsTable.id,
|
||||
conversationNameAr: conversationsTable.nameAr,
|
||||
conversationNameEn: conversationsTable.nameEn,
|
||||
conversationIsGroup: conversationsTable.isGroup,
|
||||
})
|
||||
.from(messagesTable)
|
||||
.innerJoin(
|
||||
conversationsTable,
|
||||
eq(messagesTable.conversationId, conversationsTable.id),
|
||||
)
|
||||
.where(eq(messagesTable.senderId, id))
|
||||
.orderBy(sql`${messagesTable.createdAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const params = GetUserParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
@@ -858,51 +753,21 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.userId, userId));
|
||||
const [convRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, userId));
|
||||
const [msgRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(messagesTable)
|
||||
.where(eq(messagesTable.senderId, userId));
|
||||
|
||||
const noteCount = noteRow?.count ?? 0;
|
||||
const orderCount = orderRow?.count ?? 0;
|
||||
const conversationCount = convRow?.count ?? 0;
|
||||
const messageCount = msgRow?.count ?? 0;
|
||||
const hasDeps =
|
||||
noteCount > 0 ||
|
||||
orderCount > 0 ||
|
||||
conversationCount > 0 ||
|
||||
messageCount > 0;
|
||||
const hasDeps = noteCount > 0 || orderCount > 0;
|
||||
|
||||
if (hasDeps && !force) {
|
||||
res.status(409).json({
|
||||
error: "User has dependent records",
|
||||
noteCount,
|
||||
orderCount,
|
||||
conversationCount,
|
||||
messageCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (force) {
|
||||
// Hard cleanup of records that don't cascade automatically so the
|
||||
// user delete can succeed without FK violations.
|
||||
if (conversationCount > 0) {
|
||||
await tx
|
||||
.delete(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, userId));
|
||||
}
|
||||
if (messageCount > 0) {
|
||||
await tx
|
||||
.delete(messagesTable)
|
||||
.where(eq(messagesTable.senderId, userId));
|
||||
}
|
||||
}
|
||||
await tx.delete(usersTable).where(eq(usersTable.id, userId));
|
||||
});
|
||||
|
||||
@@ -917,9 +782,7 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
displayNameAr: existing.displayNameAr,
|
||||
email: existing.email,
|
||||
force,
|
||||
...(hasDeps
|
||||
? { noteCount, orderCount, conversationCount, messageCount }
|
||||
: {}),
|
||||
...(hasDeps ? { noteCount, orderCount } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -237,17 +237,6 @@ after(async () => {
|
||||
await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = $1`, [
|
||||
uid,
|
||||
]);
|
||||
// Drop messages and conversations the user created/sent — the 409
|
||||
// user-delete test deliberately seeds these and the FK to users
|
||||
// is RESTRICT, so they must go before the user row can be removed.
|
||||
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]);
|
||||
await pool.query(
|
||||
`DELETE FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE created_by = $1)`,
|
||||
[uid],
|
||||
);
|
||||
await pool.query(`DELETE FROM conversations WHERE created_by = $1`, [
|
||||
uid,
|
||||
]);
|
||||
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
|
||||
@@ -305,20 +294,14 @@ test("DELETE /api/users/:id (no-force, no deps) writes one user.delete row with
|
||||
// No-deps path must NOT include the dependency counts.
|
||||
assert.equal(row.metadata.noteCount, undefined);
|
||||
assert.equal(row.metadata.orderCount, undefined);
|
||||
assert.equal(row.metadata.conversationCount, undefined);
|
||||
assert.equal(row.metadata.messageCount, undefined);
|
||||
});
|
||||
|
||||
test("DELETE /api/users/:id without force on a user with deps returns 409 and writes NO audit row", async () => {
|
||||
const { id } = await insertUser("u_409");
|
||||
// Give the user a dependency that blocks delete.
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`,
|
||||
[id],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
||||
[conv.rows[0].id, id],
|
||||
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${id}`, {
|
||||
@@ -348,13 +331,9 @@ test("DELETE /api/users/:id?force=true writes one user.delete row with force=tru
|
||||
const { id, username } = await insertUser("u_f");
|
||||
|
||||
// Add a dependency so the force branch is taken meaningfully.
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`,
|
||||
[id],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
||||
[conv.rows[0].id, id],
|
||||
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${id}?force=true`, {
|
||||
@@ -374,10 +353,8 @@ test("DELETE /api/users/:id?force=true writes one user.delete row with force=tru
|
||||
assert.equal(row.metadata.username, username);
|
||||
assert.equal(row.metadata.force, true);
|
||||
// Force-with-deps path records the counts.
|
||||
assert.equal(typeof row.metadata.conversationCount, "number");
|
||||
assert.equal(typeof row.metadata.messageCount, "number");
|
||||
assert.ok(row.metadata.conversationCount >= 1);
|
||||
assert.ok(row.metadata.messageCount >= 1);
|
||||
assert.equal(typeof row.metadata.noteCount, "number");
|
||||
assert.ok(row.metadata.noteCount >= 1);
|
||||
});
|
||||
|
||||
// ---------- roles ----------
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdConversationIds = [];
|
||||
|
||||
async function createUser(prefix) {
|
||||
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Leave Test', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
assert.ok(setCookie, "expected Set-Cookie header from login");
|
||||
const sid = setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
assert.ok(sid, "expected connect.sid cookie");
|
||||
return sid;
|
||||
}
|
||||
|
||||
async function createGroup(creatorId, members) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO conversations (name_en, is_group, created_by)
|
||||
VALUES ('Leave Test Group', true, $1) RETURNING id`,
|
||||
[creatorId],
|
||||
);
|
||||
const conversationId = rows[0].id;
|
||||
createdConversationIds.push(conversationId);
|
||||
|
||||
// Insert participants in a deterministic joined_at order so that auto
|
||||
// promotion picks the expected user.
|
||||
let offsetMs = 0;
|
||||
for (const m of members) {
|
||||
await pool.query(
|
||||
`INSERT INTO conversation_participants
|
||||
(conversation_id, user_id, is_admin, joined_at)
|
||||
VALUES ($1, $2, $3, NOW() - ($4 || ' milliseconds')::interval)`,
|
||||
[conversationId, m.userId, !!m.isAdmin, 10000 - offsetMs],
|
||||
);
|
||||
offsetMs += 1000;
|
||||
}
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
async function getParticipants(conversationId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT user_id AS "userId", is_admin AS "isAdmin"
|
||||
FROM conversation_participants
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY joined_at ASC`,
|
||||
[conversationId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function conversationExists(conversationId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM conversations WHERE id = $1`,
|
||||
[conversationId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function leave(conversationId, cookie, body) {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/conversations/${conversationId}/leave`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
},
|
||||
);
|
||||
if (process.env.LEAVE_TEST_DEBUG && res.status >= 400) {
|
||||
const text = await res.clone().text();
|
||||
console.error(
|
||||
`[leave debug] status=${res.status} cookie=${cookie} body=${text}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
if (createdConversationIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM messages WHERE conversation_id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM conversation_participants WHERE conversation_id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM conversations WHERE id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("solo member leaving a group deletes the conversation", async () => {
|
||||
const solo = await createUser("leave_solo");
|
||||
const conv = await createGroup(solo.id, [{ userId: solo.id, isAdmin: true }]);
|
||||
const cookie = await loginAndGetCookie(solo.username, TEST_PASSWORD);
|
||||
|
||||
const res = await leave(conv, cookie);
|
||||
assert.equal(res.status, 200);
|
||||
const json = await res.json();
|
||||
assert.equal(json.success, true);
|
||||
|
||||
assert.equal(
|
||||
await conversationExists(conv),
|
||||
false,
|
||||
"conversation should be deleted when last member leaves",
|
||||
);
|
||||
});
|
||||
|
||||
test("sole admin leaving auto-promotes the earliest remaining member", async () => {
|
||||
const admin = await createUser("leave_admin_auto");
|
||||
const m1 = await createUser("leave_m1");
|
||||
const m2 = await createUser("leave_m2");
|
||||
// admin joined first, then m1, then m2 (handled by createGroup ordering)
|
||||
const conv = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
{ userId: m2.id, isAdmin: false },
|
||||
]);
|
||||
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
|
||||
|
||||
const res = await leave(conv, cookie);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const parts = await getParticipants(conv);
|
||||
assert.equal(parts.length, 2);
|
||||
assert.ok(
|
||||
!parts.find((p) => p.userId === admin.id),
|
||||
"leaver must be removed",
|
||||
);
|
||||
const promoted = parts.find((p) => p.isAdmin);
|
||||
assert.ok(promoted, "an admin should have been auto-promoted");
|
||||
assert.equal(
|
||||
promoted.userId,
|
||||
m1.id,
|
||||
"earliest joined remaining member should be promoted",
|
||||
);
|
||||
});
|
||||
|
||||
test("sole admin leaving with a chosen successor promotes that user", async () => {
|
||||
const admin = await createUser("leave_admin_chosen");
|
||||
const m1 = await createUser("leave_chosen_m1");
|
||||
const m2 = await createUser("leave_chosen_m2");
|
||||
const conv = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
{ userId: m2.id, isAdmin: false },
|
||||
]);
|
||||
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
|
||||
|
||||
const res = await leave(conv, cookie, { successorId: m2.id });
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const parts = await getParticipants(conv);
|
||||
assert.equal(parts.length, 2);
|
||||
const promoted = parts.find((p) => p.isAdmin);
|
||||
assert.ok(promoted, "an admin should be present after handoff");
|
||||
assert.equal(
|
||||
promoted.userId,
|
||||
m2.id,
|
||||
"the chosen successor should be the new admin",
|
||||
);
|
||||
});
|
||||
|
||||
test("sole admin leaving with an invalid successor returns 400 and does not leave", async () => {
|
||||
const admin = await createUser("leave_admin_bad");
|
||||
const m1 = await createUser("leave_bad_m1");
|
||||
const outsider = await createUser("leave_bad_outsider");
|
||||
const conv = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
]);
|
||||
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
|
||||
|
||||
const res = await leave(conv, cookie, { successorId: outsider.id });
|
||||
assert.equal(res.status, 400);
|
||||
|
||||
const parts = await getParticipants(conv);
|
||||
assert.equal(parts.length, 2, "membership should be unchanged");
|
||||
const adminRow = parts.find((p) => p.userId === admin.id);
|
||||
assert.ok(adminRow, "leaver should still be a member");
|
||||
assert.equal(adminRow.isAdmin, true, "leaver should still be admin");
|
||||
const m1Row = parts.find((p) => p.userId === m1.id);
|
||||
assert.equal(m1Row.isAdmin, false, "m1 should not have been promoted");
|
||||
});
|
||||
|
||||
test("non-admin leaving a group does not promote anyone", async () => {
|
||||
const admin = await createUser("leave_keep_admin");
|
||||
const m1 = await createUser("leave_nonadmin_leaver");
|
||||
const m2 = await createUser("leave_nonadmin_other");
|
||||
const conv = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
{ userId: m2.id, isAdmin: false },
|
||||
]);
|
||||
const cookie = await loginAndGetCookie(m1.username, TEST_PASSWORD);
|
||||
|
||||
const res = await leave(conv, cookie);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const parts = await getParticipants(conv);
|
||||
assert.equal(parts.length, 2);
|
||||
assert.ok(
|
||||
!parts.find((p) => p.userId === m1.id),
|
||||
"leaver must be removed",
|
||||
);
|
||||
const admins = parts.filter((p) => p.isAdmin);
|
||||
assert.equal(admins.length, 1, "exactly the original admin remains admin");
|
||||
assert.equal(admins[0].userId, admin.id);
|
||||
const m2Row = parts.find((p) => p.userId === m2.id);
|
||||
assert.equal(m2Row.isAdmin, false, "non-admin member should not be promoted");
|
||||
});
|
||||
@@ -138,10 +138,6 @@ after(async () => {
|
||||
}
|
||||
for (const uid of [adminId, ...createdUserIds]) {
|
||||
if (uid !== undefined) {
|
||||
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM conversations WHERE created_by = $1`, [
|
||||
uid,
|
||||
]);
|
||||
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
|
||||
@@ -187,15 +183,6 @@ test("DELETE /api/users/:id with dependents returns 409 with counts", async () =
|
||||
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'Note A', 'x'), ($1, 'Note B', 'y')`,
|
||||
[uid],
|
||||
);
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Test Conv', true) RETURNING id`,
|
||||
[uid],
|
||||
);
|
||||
const convId = conv.rows[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hello')`,
|
||||
[convId, uid],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${uid}`, {
|
||||
method: "DELETE",
|
||||
@@ -204,8 +191,6 @@ test("DELETE /api/users/:id with dependents returns 409 with counts", async () =
|
||||
assert.equal(res.status, 409);
|
||||
const body = await res.json();
|
||||
assert.equal(body.noteCount, 2);
|
||||
assert.equal(body.conversationCount, 1);
|
||||
assert.equal(body.messageCount, 1);
|
||||
|
||||
const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [
|
||||
uid,
|
||||
@@ -225,14 +210,6 @@ test("DELETE /api/users/:id?force=true deletes user and writes audit log", async
|
||||
await pool.query(`INSERT INTO notes (user_id, title) VALUES ($1, 'n')`, [
|
||||
uid,
|
||||
]);
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Conv', true) RETURNING id`,
|
||||
[uid],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
||||
[conv.rows[0].id, uid],
|
||||
);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${uid}?force=true`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -23,7 +23,6 @@ let depAppId;
|
||||
let depServiceId;
|
||||
let depGroupId;
|
||||
let depPermissionId;
|
||||
let depConvId;
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
@@ -86,7 +85,7 @@ before(async () => {
|
||||
);
|
||||
depPermissionId = perm.rows[0].id;
|
||||
|
||||
// Dependents on the user: a note, an order, a conversation, a message.
|
||||
// Dependents on the user: a note and an order.
|
||||
await pool.query(
|
||||
`INSERT INTO notes (user_id, content) VALUES ($1, 'note for dep test')`,
|
||||
[depUserId],
|
||||
@@ -96,20 +95,6 @@ before(async () => {
|
||||
VALUES ($1, $2, 'pending', 'dep order')`,
|
||||
[depServiceId, depUserId],
|
||||
);
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (is_group, name_en, created_by)
|
||||
VALUES (true, 'dep conv', $1) RETURNING id`,
|
||||
[depUserId],
|
||||
);
|
||||
depConvId = conv.rows[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`,
|
||||
[depConvId, depUserId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
||||
[depConvId, depUserId],
|
||||
);
|
||||
|
||||
// App dependents: group link, permission restriction, an open record.
|
||||
await pool.query(
|
||||
@@ -129,9 +114,6 @@ before(async () => {
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM conversation_participants WHERE user_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM conversations WHERE id = $1`, [depConvId]);
|
||||
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM app_opens WHERE app_id = $1`, [depAppId]);
|
||||
@@ -170,7 +152,7 @@ test("GET /api/services exposes orderCount", async () => {
|
||||
assert.equal(target.orderCount, 1, "orderCount should be 1");
|
||||
});
|
||||
|
||||
test("GET /api/users exposes noteCount/orderCount/conversationCount/messageCount", async () => {
|
||||
test("GET /api/users exposes noteCount/orderCount", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/users`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
@@ -180,8 +162,6 @@ test("GET /api/users exposes noteCount/orderCount/conversationCount/messageCount
|
||||
assert.ok(target, "expected dep user in users list");
|
||||
assert.equal(target.noteCount, 1, "noteCount should be 1");
|
||||
assert.equal(target.orderCount, 1, "orderCount should be 1");
|
||||
assert.equal(target.conversationCount, 1, "conversationCount should be 1");
|
||||
assert.equal(target.messageCount, 1, "messageCount should be 1");
|
||||
});
|
||||
|
||||
test("Apps list returns zero counts for entities without dependents", async () => {
|
||||
@@ -248,8 +228,6 @@ test("Users list returns zero counts for users without dependents", async () =>
|
||||
assert.ok(target, "expected clean user in users list");
|
||||
assert.equal(target.noteCount, 0);
|
||||
assert.equal(target.orderCount, 0);
|
||||
assert.equal(target.conversationCount, 0);
|
||||
assert.equal(target.messageCount, 0);
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [cleanUserId]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user