diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index a545ff37..79f73421 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -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"); }); diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts deleted file mode 100644 index 03aaf586..00000000 --- a/artifacts/api-server/src/routes/conversations.ts +++ /dev/null @@ -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 { - 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`false`, - }) - .from(usersTable) - .where(eq(usersTable.id, msg.senderId)); - - lastMessage = { ...msg, sender }; - } - - const unreadResult = await db - .select({ count: sql`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 => { - 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 => { - 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 => { - 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 => { - 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 = {}; - 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 { - 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; - -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 { - 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`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 { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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`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 => { - 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`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 { - 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 => { - 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; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 43a82980..21762dfe 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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); diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts index b5c82949..cded8aa9 100644 --- a/artifacts/api-server/src/routes/stats.ts +++ b/artifacts/api-server/src/routes/stats.ts @@ -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 => { .select({ count: sql`count(*)` }) .from(usersTable); - const [unreadMsgCount] = await db - .select({ count: sql`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), }); }); diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index c12063ef..c9537854 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -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 type DependencyCounts = { noteCount: number; orderCount: number; - conversationCount: number; - messageCount: number; }; function buildUserProfile( @@ -293,27 +289,9 @@ router.get("/users", requireAdmin, async (req, res): Promise => { .from(serviceOrdersTable) .where(inArray(serviceOrdersTable.userId, userIds)) .groupBy(serviceOrdersTable.userId); - const convRows = await db - .select({ - userId: conversationsTable.createdBy, - count: sql`count(*)::int`, - }) - .from(conversationsTable) - .where(inArray(conversationsTable.createdBy, userIds)) - .groupBy(conversationsTable.createdBy); - const msgRows = await db - .select({ - userId: messagesTable.senderId, - count: sql`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 => { { 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 => { - 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`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 => { - 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`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 => { const params = GetUserParams.safeParse(req.params); if (!params.success) { @@ -858,51 +753,21 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise => { .select({ count: sql`count(*)::int` }) .from(serviceOrdersTable) .where(eq(serviceOrdersTable.userId, userId)); - const [convRow] = await db - .select({ count: sql`count(*)::int` }) - .from(conversationsTable) - .where(eq(conversationsTable.createdBy, userId)); - const [msgRow] = await db - .select({ count: sql`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 => { displayNameAr: existing.displayNameAr, email: existing.email, force, - ...(hasDeps - ? { noteCount, orderCount, conversationCount, messageCount } - : {}), + ...(hasDeps ? { noteCount, orderCount } : {}), }, }); diff --git a/artifacts/api-server/tests/audit-log-coverage.test.mjs b/artifacts/api-server/tests/audit-log-coverage.test.mjs index 3a088b69..198fd53b 100644 --- a/artifacts/api-server/tests/audit-log-coverage.test.mjs +++ b/artifacts/api-server/tests/audit-log-coverage.test.mjs @@ -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 ---------- diff --git a/artifacts/api-server/tests/conversations-leave.test.mjs b/artifacts/api-server/tests/conversations-leave.test.mjs deleted file mode 100644 index 9d35a8fe..00000000 --- a/artifacts/api-server/tests/conversations-leave.test.mjs +++ /dev/null @@ -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"); -}); diff --git a/artifacts/api-server/tests/delete-force-warnings.test.mjs b/artifacts/api-server/tests/delete-force-warnings.test.mjs index ff9b9ab3..ca22d448 100644 --- a/artifacts/api-server/tests/delete-force-warnings.test.mjs +++ b/artifacts/api-server/tests/delete-force-warnings.test.mjs @@ -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", diff --git a/artifacts/api-server/tests/list-dependency-counts.test.mjs b/artifacts/api-server/tests/list-dependency-counts.test.mjs index f96374fe..340da53b 100644 --- a/artifacts/api-server/tests/list-dependency-counts.test.mjs +++ b/artifacts/api-server/tests/list-dependency-counts.test.mjs @@ -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]); } diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index cdee6469..c5ec4918 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index ec9b3617..70c23881 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -16,7 +16,6 @@ import ForgotPasswordPage from "@/pages/forgot-password"; import ResetPasswordPage from "@/pages/reset-password"; import HomePage from "@/pages/home"; import ServicesPage from "@/pages/services"; -import ChatPage from "@/pages/chat"; import NotificationsPage from "@/pages/notifications"; import AdminPage from "@/pages/admin"; import NotesPage from "@/pages/notes"; @@ -53,7 +52,6 @@ function Router() { - diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 3dea7087..8a2ffcab 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -2,7 +2,6 @@ "nav": { "home": "الرئيسية", "services": "الخدمات", - "chat": "المحادثات", "notifications": "الإشعارات", "admin": "لوحة التحكم", "logout": "تسجيل الخروج" @@ -75,7 +74,6 @@ "stats": { "apps": "التطبيقات", "services": "الخدمات", - "messages": "الرسائل", "alerts": "التنبيهات" }, "clockStyle": { @@ -206,103 +204,6 @@ "deleteFailed": "تعذّر حذف الطلبات", "deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي" }, - "chat": { - "title": "المحادثات", - "newMessage": "رسالة جديدة", - "typeMessage": "اكتب رسالتك...", - "send": "إرسال", - "noConversations": "لا توجد محادثات", - "createConversation": "محادثة جديدة", - "groupChat": "مجموعة", - "directMessage": "محادثة خاصة", - "participants": "المشاركون", - "searchUsers": "البحث عن مستخدمين", - "modeDirect": "محادثة خاصة", - "modeGroup": "مجموعة", - "groupNameAr": "اسم المجموعة (عربي)", - "groupNameEn": "اسم المجموعة (إنجليزي)", - "groupNamePlaceholderAr": "مثلاً: فريق المبيعات", - "groupNamePlaceholderEn": "e.g. Sales team", - "searchUsersPlaceholder": "ابحث بالاسم أو اسم المستخدم", - "participantsCount_one": "{{count}} شخص مختار", - "participantsCount_other": "{{count}} أشخاص مختارين", - "participantsCount_zero": "لم يتم اختيار أحد", - "noUsersFound": "لا يوجد مستخدمون مطابقون", - "uploadAvatar": "رفع صورة", - "replaceAvatar": "استبدال الصورة", - "removeAvatar": "إزالة الصورة", - "changeAvatar": "تغيير صورة المجموعة", - "uploadingAvatar": "جارٍ الرفع… {{progress}}٪", - "avatarUploadFailed": "تعذّر رفع الصورة", - "validation": { - "needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.", - "minTwoMembers": "اختر شخصين على الأقل لإنشاء مجموعة.", - "pickOnePerson": "اختر شخصاً واحداً للمحادثة." - }, - "create": "إنشاء", - "settings": { - "title": "إعدادات المجموعة", - "openSettings": "فتح إعدادات المجموعة", - "groupName": "اسم المجموعة", - "saveName": "حفظ الاسم", - "nameSaved": "تم تحديث اسم المجموعة", - "saveFailed": "تعذّر حفظ التغييرات", - "needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.", - "members_one": "عضو واحد", - "members_two": "عضوان", - "members_few": "{{count}} أعضاء", - "members_many": "{{count}} عضواً", - "members_other": "{{count}} عضو", - "addMembers": "إضافة أعضاء", - "addSelected": "إضافة", - "membersAdded": "تمت إضافة الأعضاء", - "addFailed": "تعذّرت إضافة الأعضاء", - "removeMember": "إزالة العضو", - "removeFailed": "تعذّرت إزالة العضو", - "admin": "مشرف", - "you": "(أنت)", - "viewOnly": "فقط مشرف المجموعة يستطيع تغيير الاسم أو الأعضاء." - }, - "filterAll": "نشطة", - "filterArchived": "المؤرشفة", - "noArchived": "لا توجد محادثات مؤرشفة", - "actions": { - "title": "خيارات المحادثة", - "openActions": "فتح خيارات المحادثة", - "mute": "كتم الإشعارات", - "unmute": "إلغاء كتم الإشعارات", - "archive": "أرشفة المحادثة", - "unarchive": "إلغاء الأرشفة", - "leave": "مغادرة المجموعة", - "leaveConfirmTitle": "مغادرة هذه المجموعة؟", - "leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.", - "successorTitle": "اختر المشرف التالي", - "successorHelp": "أنت المشرف الوحيد. اختر من يتولى الإدارة، أو دعنا نرقّي أقدم عضو في المجموعة.", - "successorAuto": "تلقائي (أقدم عضو)", - "muted": "تم كتم الإشعارات", - "unmuted": "الإشعارات مفعّلة", - "archived": "تمت أرشفة المحادثة", - "unarchived": "أعيدت المحادثة إلى النشطة", - "left": "غادرت المجموعة", - "actionFailed": "تعذّر تنفيذ الإجراء", - "mutedLabel": "مكتومة", - "youAreAdminTitle": "أنت الآن المشرف", - "youAreAdminDescription": "أصبحت مسؤولًا عن \"{{name}}\".", - "newAdminBadge": "مشرف جديد", - "newAdminLabel": "مشرف جديد: {{name}}" - }, - "system": { - "someone": "شخص ما", - "listSeparator": "، ", - "groupRenamed": "غيّر {{actor}} اسم المجموعة إلى \"{{name}}\"", - "membersAdded_one": "أضاف {{actor}} {{names}}", - "membersAdded_other": "أضاف {{actor}} {{names}}", - "memberRemoved": "أزال {{actor}} {{target}}", - "memberLeft": "غادر {{actor}} المجموعة", - "adminPromoted": "{{target}} أصبح مشرفًا للمجموعة", - "adminPromotedAfterLeave": "تمت ترقية {{target}} إلى مشرف بعد مغادرة المشرف السابق" - } - }, "notifSettings": { "title": "أصوات الإشعارات", "mute": "كتم الإشعارات", @@ -381,12 +282,10 @@ "deleteUser": { "title": "حذف المستخدم \"{{name}}\"؟", "warning": "هذا المستخدم لديه بيانات. حذفه سيؤثر على ما يلي:", - "forceHint": "سيؤدي ذلك إلى حذف ملاحظاته وطلباته والمحادثات التي أنشأها والرسائل التي أرسلها بشكل نهائي. لا يمكن التراجع عن هذا الإجراء.", + "forceHint": "سيؤدي ذلك إلى حذف ملاحظاته وطلباته بشكل نهائي. لا يمكن التراجع عن هذا الإجراء.", "emptyBody": "هل أنت متأكد من حذف هذا المستخدم؟", "noteCount": "{{count}} ملاحظة", "orderCount": "{{count}} طلب", - "conversationCount": "{{count}} محادثة أنشأها", - "messageCount": "{{count}} رسالة أرسلها", "confirm": "حذف", "anyway": "حذف رغم ذلك" }, @@ -460,9 +359,7 @@ }, "counts": { "notes": "{{count}} ملاحظة", - "orders": "{{count}} طلب", - "conversations": "{{count}} محادثة", - "messages": "{{count}} رسالة" + "orders": "{{count}} طلب" }, "historyTitle": "سجل الصلاحيات", "historyHint": "الأدوار والمجموعات المسندة لهذا المستخدم. صفِّ بحسب من غيّر أو متى.", @@ -796,18 +693,6 @@ "note_few": "{{count}} ملاحظات", "note_many": "{{count}} ملاحظةً", "note_other": "{{count}} ملاحظة", - "conversation_zero": "بدون محادثات", - "conversation_one": "محادثة واحدة", - "conversation_two": "محادثتان", - "conversation_few": "{{count}} محادثات", - "conversation_many": "{{count}} محادثةً", - "conversation_other": "{{count}} محادثة", - "message_zero": "بدون رسائل", - "message_one": "رسالة واحدة", - "message_two": "رسالتان", - "message_few": "{{count}} رسائل", - "message_many": "{{count}} رسالةً", - "message_other": "{{count}} رسالة", "open_zero": "بدون فتحات", "open_one": "فتحة واحدة", "open_two": "فتحتان", @@ -990,18 +875,6 @@ "title": "الطلبات · {{name}}", "subtitle": "{{count}} طلب" }, - "userConversations": { - "title": "المحادثات · {{name}}", - "subtitle": "{{count}} محادثة", - "kindGroup": "مجموعة", - "kindDirect": "خاصة", - "unnamedGroup": "(مجموعة بدون اسم)", - "unnamedDirect": "محادثة خاصة" - }, - "userMessages": { - "title": "الرسائل · {{name}}", - "subtitle": "{{count}} رسالة" - }, "orderStatus": { "pending": "قيد الانتظار", "received": "مستلم", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index a11d4b13..b258c074 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -2,7 +2,6 @@ "nav": { "home": "Home", "services": "Services", - "chat": "Chat", "notifications": "Notifications", "admin": "Admin Dashboard", "logout": "Logout" @@ -75,7 +74,6 @@ "stats": { "apps": "Apps", "services": "Services", - "messages": "Messages", "alerts": "Alerts" }, "clockStyle": { @@ -206,100 +204,6 @@ "deleteFailed": "Could not delete the orders", "deletePartial": "Deleted {{deleted}} of {{total}}; the rest could not be deleted" }, - "chat": { - "title": "Chat", - "newMessage": "New Message", - "typeMessage": "Type a message...", - "send": "Send", - "noConversations": "No conversations", - "createConversation": "New Conversation", - "groupChat": "Group", - "directMessage": "Direct Message", - "participants": "Participants", - "searchUsers": "Search users", - "modeDirect": "Direct", - "modeGroup": "Group", - "groupNameAr": "Group name (Arabic)", - "groupNameEn": "Group name (English)", - "groupNamePlaceholderAr": "مثلاً: فريق المبيعات", - "groupNamePlaceholderEn": "e.g. Sales team", - "searchUsersPlaceholder": "Search by name or username", - "participantsCount_one": "{{count}} person selected", - "participantsCount_other": "{{count}} people selected", - "participantsCount_zero": "No one selected", - "noUsersFound": "No matching users", - "uploadAvatar": "Upload picture", - "replaceAvatar": "Replace picture", - "removeAvatar": "Remove picture", - "changeAvatar": "Change group picture", - "uploadingAvatar": "Uploading… {{progress}}%", - "avatarUploadFailed": "Could not upload picture", - "validation": { - "needGroupName": "Enter a group name in Arabic or English.", - "minTwoMembers": "Pick at least 2 people for a group.", - "pickOnePerson": "Pick one person to chat with." - }, - "create": "Create", - "settings": { - "title": "Group settings", - "openSettings": "Open group settings", - "groupName": "Group name", - "saveName": "Save name", - "nameSaved": "Group name updated", - "saveFailed": "Could not save changes", - "needGroupName": "Enter a group name in Arabic or English.", - "members_one": "{{count}} member", - "members_other": "{{count}} members", - "addMembers": "Add members", - "addSelected": "Add", - "membersAdded": "Members added", - "addFailed": "Could not add members", - "removeMember": "Remove member", - "removeFailed": "Could not remove member", - "admin": "Admin", - "you": "(you)", - "viewOnly": "Only the group admin can change the name or members." - }, - "filterAll": "Active", - "filterArchived": "Archived", - "noArchived": "No archived chats", - "actions": { - "title": "Chat options", - "openActions": "Open chat options", - "mute": "Mute notifications", - "unmute": "Unmute notifications", - "archive": "Archive chat", - "unarchive": "Unarchive chat", - "leave": "Leave group", - "leaveConfirmTitle": "Leave this group?", - "leaveConfirmBody": "You will be removed from \"{{name}}\" and stop receiving its messages.", - "successorTitle": "Choose the next admin", - "successorHelp": "You're the only admin. Pick who should take over, or let us promote the longest-tenured member.", - "successorAuto": "Auto (longest-tenured member)", - "muted": "Notifications muted", - "unmuted": "Notifications on", - "archived": "Chat archived", - "unarchived": "Chat moved back to active", - "left": "You left the group", - "actionFailed": "Could not complete action", - "mutedLabel": "Muted", - "youAreAdminTitle": "You are now the admin", - "youAreAdminDescription": "You're now in charge of \"{{name}}\".", - "newAdminBadge": "New admin", - "newAdminLabel": "New admin: {{name}}" - }, - "system": { - "someone": "Someone", - "listSeparator": ", ", - "groupRenamed": "{{actor}} renamed the group to \"{{name}}\"", - "membersAdded_one": "{{actor}} added {{names}}", - "membersAdded_other": "{{actor}} added {{names}}", - "memberRemoved": "{{actor}} removed {{target}}", - "memberLeft": "{{actor}} left the group", - "adminPromoted": "{{target}} is now a group admin", - "adminPromotedAfterLeave": "{{target}} was promoted to admin after the previous admin left" - } - }, "notifications": { "title": "Notifications", "markAllRead": "Mark All Read", @@ -378,12 +282,10 @@ "deleteUser": { "title": "Delete user \"{{name}}\"?", "warning": "This user owns data. Deleting them will affect the following:", - "forceHint": "This will permanently remove their notes, orders, conversations they started, and messages they sent. This cannot be undone.", + "forceHint": "This will permanently remove their notes and orders. This cannot be undone.", "emptyBody": "Are you sure you want to delete this user?", "noteCount": "{{count}} note(s)", "orderCount": "{{count}} order(s)", - "conversationCount": "{{count}} conversation(s) created", - "messageCount": "{{count}} message(s) sent", "confirm": "Delete", "anyway": "Delete anyway" }, @@ -457,9 +359,7 @@ }, "counts": { "notes": "{{count}} notes", - "orders": "{{count}} orders", - "conversations": "{{count}} convos", - "messages": "{{count}} messages" + "orders": "{{count}} orders" }, "historyTitle": "Permission history", "historyHint": "Roles and groups assigned to this user. Filter by who changed it or when.", @@ -709,10 +609,6 @@ "restriction_other": "{{count}} restrictions", "note_one": "{{count}} note", "note_other": "{{count}} notes", - "conversation_one": "{{count}} conversation", - "conversation_other": "{{count}} conversations", - "message_one": "{{count}} message", - "message_other": "{{count}} messages", "open_one": "{{count}} open", "open_other": "{{count}} opens" }, @@ -891,18 +787,6 @@ "title": "Orders · {{name}}", "subtitle": "{{count}} orders" }, - "userConversations": { - "title": "Conversations · {{name}}", - "subtitle": "{{count}} conversations", - "kindGroup": "Group", - "kindDirect": "Direct", - "unnamedGroup": "(unnamed group)", - "unnamedDirect": "Direct chat" - }, - "userMessages": { - "title": "Messages · {{name}}", - "subtitle": "{{count}} messages" - }, "orderStatus": { "pending": "Pending", "received": "Received", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 0ee6d165..4aa99983 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -46,18 +46,12 @@ import { getGetAdminUserDependentNotesQueryKey, useGetAdminUserDependentOrders, getGetAdminUserDependentOrdersQueryKey, - useGetAdminUserDependentConversations, - getGetAdminUserDependentConversationsQueryKey, - useGetAdminUserDependentMessages, - getGetAdminUserDependentMessagesQueryKey, getAdminAppDependentGroups, getAdminAppDependentRestrictions, getAdminAppDependentOpens, getAdminServiceDependentOrders, getAdminUserDependentNotes, getAdminUserDependentOrders, - getAdminUserDependentConversations, - getAdminUserDependentMessages, useAdminIssueResetLink, useListGroups, getListGroupsQueryKey, @@ -130,8 +124,6 @@ import type { ServiceDependentOrdersPage, UserDependentNotesPage, UserDependentOrdersPage, - UserDependentConversationsPage, - UserDependentMessagesPage, } from "@workspace/api-client-react"; import { ListUsersStatus } from "@workspace/api-client-react"; import { useQueryClient, useQuery } from "@tanstack/react-query"; @@ -3182,9 +3174,7 @@ type DependencyTarget = | { kind: "appOpens"; appId: number; appName: string } | { kind: "serviceOrders"; serviceId: number; serviceName: string } | { kind: "userNotes"; userId: number; userLabel: string } - | { kind: "userOrders"; userId: number; userLabel: string } - | { kind: "userConversations"; userId: number; userLabel: string } - | { kind: "userMessages"; userId: number; userLabel: string }; + | { kind: "userOrders"; userId: number; userLabel: string }; type DependencyPage = | AppDependentGroupsPage @@ -3192,9 +3182,7 @@ type DependencyPage = | AppDependentOpensPage | ServiceDependentOrdersPage | UserDependentNotesPage - | UserDependentOrdersPage - | UserDependentConversationsPage - | UserDependentMessagesPage; + | UserDependentOrdersPage; const DEPENDENT_PAGE_LIMIT = 50; @@ -3314,10 +3302,7 @@ function useDependencyInitial(target: DependencyTarget): { : 0; const serviceId = target.kind === "serviceOrders" ? target.serviceId : 0; const userId = - target.kind === "userNotes" || - target.kind === "userOrders" || - target.kind === "userConversations" || - target.kind === "userMessages" + target.kind === "userNotes" || target.kind === "userOrders" ? target.userId : 0; @@ -3365,20 +3350,6 @@ function useDependencyInitial(target: DependencyTarget): { retry: false, }, }); - const uConvos = useGetAdminUserDependentConversations(userId, params, { - query: { - queryKey: getGetAdminUserDependentConversationsQueryKey(userId, params), - enabled: enabledFor("userConversations"), - retry: false, - }, - }); - const uMessages = useGetAdminUserDependentMessages(userId, params, { - query: { - queryKey: getGetAdminUserDependentMessagesQueryKey(userId, params), - enabled: enabledFor("userMessages"), - retry: false, - }, - }); switch (target.kind) { case "appGroups": @@ -3393,10 +3364,6 @@ function useDependencyInitial(target: DependencyTarget): { return uNotes; case "userOrders": return uOrders; - case "userConversations": - return uConvos; - case "userMessages": - return uMessages; } } @@ -3417,10 +3384,6 @@ async function fetchDependentsPage( return getAdminUserDependentNotes(target.userId, params); case "userOrders": return getAdminUserDependentOrders(target.userId, params); - case "userConversations": - return getAdminUserDependentConversations(target.userId, params); - case "userMessages": - return getAdminUserDependentMessages(target.userId, params); } } @@ -3460,16 +3423,6 @@ function describeDependencyTarget( title: t("admin.dependents.userOrders.title", { name: target.userLabel }), subtitle: t("admin.dependents.userOrders.subtitle", { count: totalCount }), }; - case "userConversations": - return { - title: t("admin.dependents.userConversations.title", { name: target.userLabel }), - subtitle: t("admin.dependents.userConversations.subtitle", { count: totalCount }), - }; - case "userMessages": - return { - title: t("admin.dependents.userMessages.title", { name: target.userLabel }), - subtitle: t("admin.dependents.userMessages.subtitle", { count: totalCount }), - }; } } @@ -3603,57 +3556,6 @@ function DependencyRow({ ); } - case "userConversations": { - const c = item as UserDependentConversationsPage["items"][number]; - const name = - (lang === "ar" ? c.nameAr : c.nameEn) ?? - t( - c.isGroup - ? "admin.dependents.userConversations.unnamedGroup" - : "admin.dependents.userConversations.unnamedDirect", - ); - return ( -
-
-
{name}
-
- {t( - c.isGroup - ? "admin.dependents.userConversations.kindGroup" - : "admin.dependents.userConversations.kindDirect", - )} -
-
-
- {formatOpenTimestamp(c.createdAt, lang)} -
-
- ); - } - case "userMessages": { - const m = item as UserDependentMessagesPage["items"][number]; - const where = - (lang === "ar" ? m.conversationNameAr : m.conversationNameEn) ?? - t( - m.conversationIsGroup - ? "admin.dependents.userConversations.unnamedGroup" - : "admin.dependents.userConversations.unnamedDirect", - ); - // Trim to a single-line preview so a long message doesn't blow up - // the popover height. - const preview = m.content.length > 160 ? `${m.content.slice(0, 160)}…` : m.content; - return ( -
-
-
{where}
-
- {formatOpenTimestamp(m.createdAt, lang)} -
-
-
{preview}
-
- ); - } } } @@ -3865,18 +3767,6 @@ function UsersPanel({ target: { kind: "userOrders", userId: u.id, userLabel }, testid: `user-counts-orders-${u.id}`, }); - if ((u.conversationCount ?? 0) > 0) - parts.push({ - label: t("admin.users.counts.conversations", { count: u.conversationCount }), - target: { kind: "userConversations", userId: u.id, userLabel }, - testid: `user-counts-conversations-${u.id}`, - }); - if ((u.messageCount ?? 0) > 0) - parts.push({ - label: t("admin.users.counts.messages", { count: u.messageCount }), - target: { kind: "userMessages", userId: u.id, userLabel }, - testid: `user-counts-messages-${u.id}`, - }); if (parts.length === 0) return null; return (
0 || - orderCount > 0 || - conversationCount > 0 || - messageCount > 0; + const hasDeps = noteCount > 0 || orderCount > 0; setUserDeleteConflict( hasDeps ? { error: "User has dependent records", noteCount, orderCount, - conversationCount, - messageCount, } : null, ); @@ -4098,10 +3980,6 @@ function UsersPanel({ items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount })); if (userDeleteConflict.orderCount > 0) items.push(t("admin.deleteUser.orderCount", { count: userDeleteConflict.orderCount })); - if (userDeleteConflict.conversationCount > 0) - items.push(t("admin.deleteUser.conversationCount", { count: userDeleteConflict.conversationCount })); - if (userDeleteConflict.messageCount > 0) - items.push(t("admin.deleteUser.messageCount", { count: userDeleteConflict.messageCount })); } return ( ["t"]; // `unitLabel`. Keys not in this map are skipped from the inline chip strip. const DEPENDENCY_COUNT_KEYS: Record = { orderCount: "order", - messageCount: "message", noteCount: "note", - conversationCount: "conversation", groupCount: "group", memberCount: "member", appCount: "app", diff --git a/artifacts/tx-os/src/pages/chat.tsx b/artifacts/tx-os/src/pages/chat.tsx deleted file mode 100644 index 6c12d097..00000000 --- a/artifacts/tx-os/src/pages/chat.tsx +++ /dev/null @@ -1,1716 +0,0 @@ -import { useState, useEffect, useRef, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { useLocation, useSearch } from "wouter"; -import { - useListConversations, - getListConversationsQueryKey, - useListMessages, - getListMessagesQueryKey, - useSendMessage, - useMarkConversationRead, - useCreateConversation, - useUpdateConversation, - useAddConversationParticipants, - useRemoveConversationParticipant, - useUpdateConversationState, - useLeaveConversation, -} from "@workspace/api-client-react"; -import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; -import { resolveServiceImageUrl } from "@/lib/image-url"; -import { useToast } from "@/hooks/use-toast"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/contexts/AuthContext"; -import { io } from "socket.io-client"; -import { - ArrowRight, - ArrowLeft, - MessageCircle, - Plus, - Send, - Search, - Users, - User as UserIcon, - Settings, - UserPlus, - Trash2, - MoreVertical, - Bell, - BellOff, - Archive, - ArchiveRestore, - LogOut, - Crown, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { formatTime } from "@/lib/i18n-format"; -import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Camera, Loader2, X } from "lucide-react"; - -const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); - -type UserDisplay = { - id: number; - username: string; - displayNameAr?: string | null; - displayNameEn?: string | null; -}; - -function pickDisplayName(u: UserDisplay | null | undefined, lang: string): string { - if (!u) return ""; - return (lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username; -} - -type SystemMessageMeta = { - actor?: UserDisplay | null; - oldNameAr?: string | null; - oldNameEn?: string | null; - newNameAr?: string | null; - newNameEn?: string | null; - addedUsers?: UserDisplay[]; - removedUser?: UserDisplay | null; - promoted?: UserDisplay | null; - reason?: string | null; -}; - -type SystemRenderable = { - kind?: string | null; - meta?: unknown; -}; - -function renderSystemMessage( - msg: SystemRenderable, - lang: string, - t: (k: string, opts?: Record) => string, -): string { - const meta = (msg.meta ?? {}) as SystemMessageMeta; - const actor = pickDisplayName(meta.actor, lang) || t("chat.system.someone"); - switch (msg.kind) { - case "group_renamed": { - const newName = - (lang === "ar" ? meta.newNameAr : meta.newNameEn) ?? - (lang === "ar" ? meta.newNameEn : meta.newNameAr) ?? - ""; - return t("chat.system.groupRenamed", { actor, name: newName }); - } - case "members_added": { - const names = (meta.addedUsers ?? []) - .map((u) => pickDisplayName(u, lang)) - .filter(Boolean); - return t("chat.system.membersAdded", { - actor, - names: names.join(t("chat.system.listSeparator")), - count: names.length, - }); - } - case "member_removed": { - const target = pickDisplayName(meta.removedUser, lang) || t("chat.system.someone"); - return t("chat.system.memberRemoved", { actor, target }); - } - case "member_left": { - return t("chat.system.memberLeft", { actor }); - } - case "admin_promoted": { - const target = pickDisplayName(meta.promoted, lang) || t("chat.system.someone"); - const key = - meta.reason === "previous_admin_left" - ? "chat.system.adminPromotedAfterLeave" - : "chat.system.adminPromoted"; - return t(key, { target }); - } - default: - return ""; - } -} - -function getSocket() { - return io(window.location.origin, { - path: `${BASE}/api/socket.io`, - }); -} - -type ConvMode = "direct" | "group"; - -export default function ChatPage() { - const { t, i18n } = useTranslation(); - const [, setLocation] = useLocation(); - const search = useSearch(); - const { user } = useAuth(); - const lang = i18n.language; - const isRtl = lang === "ar"; - const queryClient = useQueryClient(); - const BackIcon = isRtl ? ArrowRight : ArrowLeft; - - const [selectedConvId, setSelectedConvId] = useState(null); - const [messageText, setMessageText] = useState(""); - const [showNewConv, setShowNewConv] = useState(false); - const [mode, setMode] = useState("direct"); - const [selectedUserIds, setSelectedUserIds] = useState([]); - const [groupNameAr, setGroupNameAr] = useState(""); - const [groupNameEn, setGroupNameEn] = useState(""); - const [groupAvatarUrl, setGroupAvatarUrl] = useState(null); - const [userSearch, setUserSearch] = useState(""); - const [showSettings, setShowSettings] = useState(false); - const [showAddMembers, setShowAddMembers] = useState(false); - const [showActions, setShowActions] = useState(false); - const [showArchived, setShowArchived] = useState(false); - const [confirmLeave, setConfirmLeave] = useState(false); - const [successorChoice, setSuccessorChoice] = useState("auto"); - const [editNameAr, setEditNameAr] = useState(""); - const [editNameEn, setEditNameEn] = useState(""); - const [addMembersIds, setAddMembersIds] = useState([]); - const [addMembersSearch, setAddMembersSearch] = useState(""); - const bottomRef = useRef(null); - const { toast } = useToast(); - - const { uploadFile: uploadGroupAvatar, isUploading: isUploadingNewAvatar, progress: newAvatarProgress } = useUpload({ - onSuccess: (resp: UploadResponse) => setGroupAvatarUrl(resp.objectPath), - onError: (err: Error) => - toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" }), - }); - - const { data: conversations, isLoading: convsLoading } = useListConversations({ - query: { queryKey: getListConversationsQueryKey() }, - }); - - const { data: messages } = useListMessages( - selectedConvId!, - { query: { queryKey: getListMessagesQueryKey(selectedConvId!), enabled: !!selectedConvId } }, - ); - - type DirectoryUser = { - id: number; - username: string; - displayNameAr: string | null; - displayNameEn: string | null; - avatarUrl: string | null; - isActive: boolean; - }; - const { data: users } = useQuery({ - queryKey: ["users-directory"], - queryFn: () => - fetch("/api/users/directory", { credentials: "include" }).then((r) => r.json()), - enabled: showNewConv || showAddMembers, - }); - - const sendMessage = useSendMessage(); - const markRead = useMarkConversationRead(); - const createConv = useCreateConversation(); - - // Socket.IO - useEffect(() => { - if (!user) return; - const socket = getSocket(); - - if (selectedConvId) { - socket.emit("join_conversation", selectedConvId); - } - - socket.on("new_message", () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - if (selectedConvId) { - queryClient.invalidateQueries({ queryKey: getListMessagesQueryKey(selectedConvId) }); - } - }); - - socket.on("messages_read", () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - if (selectedConvId) { - queryClient.invalidateQueries({ queryKey: getListMessagesQueryKey(selectedConvId) }); - } - }); - - socket.on("conversation_updated", () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - }); - - return () => { - if (selectedConvId) { - socket.emit("leave_conversation", selectedConvId); - } - socket.disconnect(); - }; - }, [user, selectedConvId, queryClient]); - - // Open conversation specified via ?c= query param (e.g. from notifications) - useEffect(() => { - const params = new URLSearchParams(search); - const convParam = params.get("c"); - if (!convParam) return; - const convId = Number(convParam); - if (!Number.isFinite(convId) || convId <= 0) return; - setSelectedConvId(convId); - setLocation("/chat", { replace: true }); - }, [search, setLocation]); - - // Notify the current user when they have been promoted to admin of a group - // (e.g. after the previous admin left). We track per-user the set of group - // conversation IDs we have already observed the user being admin of; when a - // new one appears we toast. Using participant.isAdmin rather than only the - // lastMessage means the cue still fires reliably even if newer messages - // arrive in the conversation before the user next opens chat. - useEffect(() => { - if (!user || !conversations) return; - const ackKey = `tx:admin-known:${user.id}`; - let ack: number[] = []; - try { - ack = JSON.parse(localStorage.getItem(ackKey) ?? "[]"); - if (!Array.isArray(ack)) ack = []; - } catch { - ack = []; - } - const known = new Set(ack); - - // Conversations where the current user is currently an admin. - const currentAdminGroupIds = new Set(); - for (const conv of conversations) { - if (!conv.isGroup) continue; - const me = conv.participants?.find((p) => p.id === user.id); - if (me?.isAdmin) currentAdminGroupIds.add(conv.id); - } - - // First load for this user/browser: just record the baseline silently - // so we don't spam toasts about pre-existing admin roles. - if (known.size === 0 && localStorage.getItem(ackKey) === null) { - try { - localStorage.setItem( - ackKey, - JSON.stringify(Array.from(currentAdminGroupIds)), - ); - } catch { - // ignore storage errors - } - return; - } - - let changed = false; - for (const convId of currentAdminGroupIds) { - if (known.has(convId)) continue; - const conv = conversations.find((c) => c.id === convId); - if (!conv) continue; - const convDisplay = convDisplayName(conv); - toast({ - title: t("chat.actions.youAreAdminTitle"), - description: t("chat.actions.youAreAdminDescription", { - name: convDisplay, - }), - }); - known.add(convId); - changed = true; - } - // Forget conversations the user is no longer an admin of (or has left) - // so a future re-promotion will toast again. - for (const convId of Array.from(known)) { - if (!currentAdminGroupIds.has(convId)) { - known.delete(convId); - changed = true; - } - } - if (changed) { - try { - localStorage.setItem(ackKey, JSON.stringify(Array.from(known))); - } catch { - // ignore storage errors - } - } - }, [conversations, user, toast, t, lang]); - - // Mark conversation as read when selected - useEffect(() => { - if (selectedConvId) { - markRead.mutate( - { id: selectedConvId }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - }, - }, - ); - } - }, [selectedConvId]); - - // Scroll to bottom on new messages - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); - - const handleSend = () => { - if (!messageText.trim() || !selectedConvId) return; - sendMessage.mutate( - { id: selectedConvId, data: { content: messageText.trim() } }, - { - onSuccess: () => { - setMessageText(""); - queryClient.invalidateQueries({ queryKey: getListMessagesQueryKey(selectedConvId!) }); - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - }, - }, - ); - }; - - const resetNewConvState = () => { - setMode("direct"); - setSelectedUserIds([]); - setGroupNameAr(""); - setGroupNameEn(""); - setGroupAvatarUrl(null); - setUserSearch(""); - }; - - const openNewConv = () => { - resetNewConvState(); - setShowNewConv(true); - }; - - const closeNewConv = () => { - setShowNewConv(false); - resetNewConvState(); - }; - - const switchMode = (next: ConvMode) => { - setMode(next); - if (next === "direct") { - if (selectedUserIds.length > 1) { - setSelectedUserIds(selectedUserIds.slice(0, 1)); - } - setGroupNameAr(""); - setGroupNameEn(""); - } - }; - - const toggleUser = (uid: number) => { - if (mode === "direct") { - setSelectedUserIds(selectedUserIds.includes(uid) ? [] : [uid]); - } else { - setSelectedUserIds( - selectedUserIds.includes(uid) - ? selectedUserIds.filter((id) => id !== uid) - : [...selectedUserIds, uid], - ); - } - }; - - const filteredUsers = useMemo(() => { - const list = (users ?? []).filter((u) => u.id !== user?.id); - const q = userSearch.trim().toLowerCase(); - if (!q) return list; - return list.filter((u) => { - const fields = [u.username, u.displayNameAr ?? "", u.displayNameEn ?? ""]; - return fields.some((f) => f.toLowerCase().includes(q)); - }); - }, [users, userSearch, user?.id]); - - const validation: { ok: boolean; message: string | null } = useMemo(() => { - if (mode === "direct") { - if (selectedUserIds.length !== 1) { - return { ok: false, message: t("chat.validation.pickOnePerson") }; - } - return { ok: true, message: null }; - } - if (selectedUserIds.length < 2) { - return { ok: false, message: t("chat.validation.minTwoMembers") }; - } - if (!groupNameAr.trim() && !groupNameEn.trim()) { - return { ok: false, message: t("chat.validation.needGroupName") }; - } - return { ok: true, message: null }; - }, [mode, selectedUserIds, groupNameAr, groupNameEn, t]); - - const handleCreateConv = () => { - if (!validation.ok) return; - const isGroup = mode === "group"; - createConv.mutate( - { - data: { - participantIds: selectedUserIds, - isGroup, - nameAr: isGroup ? (groupNameAr.trim() || null) : null, - nameEn: isGroup ? (groupNameEn.trim() || null) : null, - avatarUrl: isGroup ? groupAvatarUrl : null, - }, - }, - { - onSuccess: (conv) => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - setSelectedConvId(conv.id); - closeNewConv(); - }, - }, - ); - }; - - const selectedConv = conversations?.find((c) => c.id === selectedConvId); - const convDisplayName = ( - conv: NonNullable, - ): string => { - const langName = lang === "ar" ? conv.nameAr : conv.nameEn; - const otherName = lang === "ar" ? conv.nameEn : conv.nameAr; - if (langName) return langName; - if (otherName) return otherName; - const others = conv.participants - ?.filter((p) => p.id !== user?.id) - .map((p) => (lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username); - if (others?.length) return others.join(", "); - return t("chat.directMessage"); - }; - - const convName = selectedConv ? convDisplayName(selectedConv) : ""; - const isAdminOfSelected = !!selectedConv?.participants?.find( - (p) => p.id === user?.id && p.isAdmin, - ); - - const updateConv = useUpdateConversation(); - const addParticipants = useAddConversationParticipants(); - const removeParticipant = useRemoveConversationParticipant(); - const updateConvState = useUpdateConversationState(); - const leaveConv = useLeaveConversation(); - - const handleToggleMute = () => { - if (!selectedConv) return; - const next = !selectedConv.isMuted; - updateConvState.mutate( - { id: selectedConv.id, data: { isMuted: next } }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - toast({ title: t(next ? "chat.actions.muted" : "chat.actions.unmuted") }); - }, - onError: (err: Error) => - toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }), - }, - ); - }; - - const handleToggleArchive = () => { - if (!selectedConv) return; - const next = !selectedConv.isArchived; - updateConvState.mutate( - { id: selectedConv.id, data: { isArchived: next } }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - toast({ title: t(next ? "chat.actions.archived" : "chat.actions.unarchived") }); - if (next) { - setShowActions(false); - setSelectedConvId(null); - } - }, - onError: (err: Error) => - toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }), - }, - ); - }; - - const handleLeave = () => { - if (!selectedConv) return; - const data = - successorChoice === "auto" - ? {} - : { successorId: successorChoice }; - leaveConv.mutate( - { id: selectedConv.id, data }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - toast({ title: t("chat.actions.left") }); - setConfirmLeave(false); - setShowActions(false); - setSelectedConvId(null); - setSuccessorChoice("auto"); - }, - onError: (err: Error) => - toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }), - }, - ); - }; - - const openSettings = () => { - if (!selectedConv) return; - setEditNameAr(selectedConv.nameAr ?? ""); - setEditNameEn(selectedConv.nameEn ?? ""); - setShowSettings(true); - }; - - const closeSettings = () => { - setShowSettings(false); - setShowAddMembers(false); - setAddMembersIds([]); - setAddMembersSearch(""); - }; - - const handleSaveName = () => { - if (!selectedConvId) return; - const ar = editNameAr.trim(); - const en = editNameEn.trim(); - if (!ar && !en) { - toast({ - title: t("chat.settings.needGroupName"), - variant: "destructive", - }); - return; - } - updateConv.mutate( - { id: selectedConvId, data: { nameAr: ar || null, nameEn: en || null } }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - toast({ title: t("chat.settings.nameSaved") }); - }, - onError: (err: Error) => { - toast({ - title: t("chat.settings.saveFailed"), - description: err.message, - variant: "destructive", - }); - }, - }, - ); - }; - - const handleAddMembers = () => { - if (!selectedConvId || addMembersIds.length === 0) return; - addParticipants.mutate( - { id: selectedConvId, data: { userIds: addMembersIds } }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - setAddMembersIds([]); - setAddMembersSearch(""); - setShowAddMembers(false); - toast({ title: t("chat.settings.membersAdded") }); - }, - onError: (err: Error) => { - toast({ - title: t("chat.settings.addFailed"), - description: err.message, - variant: "destructive", - }); - }, - }, - ); - }; - - const handleRemoveMember = (memberId: number) => { - if (!selectedConvId) return; - removeParticipant.mutate( - { id: selectedConvId, userId: memberId }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - }, - onError: (err: Error) => { - toast({ - title: t("chat.settings.removeFailed"), - description: err.message, - variant: "destructive", - }); - }, - }, - ); - }; - - const existingMemberIds = useMemo( - () => new Set(selectedConv?.participants?.map((p) => p.id) ?? []), - [selectedConv], - ); - const addableUsers = useMemo(() => { - const list = (users ?? []).filter((u) => !existingMemberIds.has(u.id)); - const q = addMembersSearch.trim().toLowerCase(); - if (!q) return list; - return list.filter((u) => { - const fields = [u.username, u.displayNameAr ?? "", u.displayNameEn ?? ""]; - return fields.some((f) => f.toLowerCase().includes(q)); - }); - }, [users, existingMemberIds, addMembersSearch]); - - const toggleAddMember = (uid: number) => { - setAddMembersIds((prev) => - prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid], - ); - }; - - const nameDirty = - !!selectedConv && - selectedConv.isGroup && - (editNameAr.trim() !== (selectedConv.nameAr ?? "") || - editNameEn.trim() !== (selectedConv.nameEn ?? "")); - const { uploadFile: uploadHeaderAvatar, isUploading: isUploadingHeaderAvatar } = useUpload({ - onSuccess: (resp: UploadResponse) => { - if (!selectedConvId) return; - updateConv.mutate( - { id: selectedConvId, data: { avatarUrl: resp.objectPath } }, - { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); - }, - onError: (err: Error) => { - toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" }); - }, - }, - ); - }, - onError: (err: Error) => - toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" }), - }); - - return ( -
- {/* Header */} -
- -
- {selectedConvId && selectedConv ? ( -
- - {selectedConv.avatarUrl && ( - - )} - - {selectedConv.isGroup ? ( - - ) : ( - String(convName).slice(0, 2).toUpperCase() - )} - - - {selectedConv.isGroup && isAdminOfSelected && ( - - )} - {selectedConv.isGroup && isAdminOfSelected && selectedConv.avatarUrl && ( - - )} -
- ) : ( - - )} - {selectedConvId && selectedConv?.isGroup ? ( - - ) : ( -

- {selectedConvId ? convName : t("chat.title")} -

- )} -
- {selectedConvId && selectedConv?.isGroup && ( - - )} - {selectedConvId && selectedConv && ( - - )} - {!selectedConvId && ( - - )} -
- - {/* New Conversation Dialog */} - {showNewConv && ( -
-
e.stopPropagation()} - > -

- {t("chat.createConversation")} -

- - {/* Mode tabs */} -
- - -
- - {/* Group name fields */} - {mode === "group" && ( -
-
-
- - {groupAvatarUrl && ( - - )} - - - - - {groupAvatarUrl && !isUploadingNewAvatar && ( - - )} -
- -
- setGroupNameAr(e.target.value)} - placeholder={t("chat.groupNamePlaceholderAr")} - aria-label={t("chat.groupNameAr")} - dir="rtl" - className="bg-white/70 border-slate-200" - /> - setGroupNameEn(e.target.value)} - placeholder={t("chat.groupNamePlaceholderEn")} - aria-label={t("chat.groupNameEn")} - dir="ltr" - className="bg-white/70 border-slate-200" - /> -
- )} - - {/* Search */} -
- - setUserSearch(e.target.value)} - placeholder={t("chat.searchUsersPlaceholder")} - className={`bg-white/70 border-slate-200 ${isRtl ? "pr-9" : "pl-9"}`} - /> -
- - {/* Counter */} -
- - {t("chat.participantsCount", { count: selectedUserIds.length })} - -
- - {/* User list */} - -
- {filteredUsers.length === 0 ? ( -
- {t("chat.noUsersFound")} -
- ) : ( - filteredUsers.map((u) => { - const checked = selectedUserIds.includes(u.id); - const display = - (lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username; - return ( - - ); - }) - )} -
-
- - {/* Validation message */} - {validation.message && ( -

- {validation.message} -

- )} - - {/* Actions */} -
- - -
-
-
- )} - - {/* Group Settings Dialog */} - {showSettings && selectedConv?.isGroup && ( -
-
e.stopPropagation()} - > -
-

- {t("chat.settings.title")} -

- -
- - {/* Name editing (admin only) */} - {isAdminOfSelected ? ( -
- - setEditNameAr(e.target.value)} - placeholder={t("chat.groupNamePlaceholderAr")} - aria-label={t("chat.groupNameAr")} - dir="rtl" - className="bg-white/70 border-slate-200" - /> - setEditNameEn(e.target.value)} - placeholder={t("chat.groupNamePlaceholderEn")} - aria-label={t("chat.groupNameEn")} - dir="ltr" - className="bg-white/70 border-slate-200" - /> - -
- ) : ( -
- {t("chat.settings.viewOnly")} -
- )} - - {/* Members list */} -
-
- - {isAdminOfSelected && ( - - )} -
-
- {selectedConv.participants?.map((p) => { - const display = - (lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username; - const isMe = p.id === user?.id; - return ( -
- - {p.avatarUrl && ( - - )} - - {display.slice(0, 2).toUpperCase()} - - -
-
- {display} - {p.isAdmin && ( - - {t("chat.settings.admin")} - - )} - {isMe && ( - - {t("chat.settings.you")} - - )} -
-
- @{p.username} -
-
- {isAdminOfSelected && !isMe && ( - - )} -
- ); - })} -
-
-
-
- )} - - {/* Add Members Dialog */} - {showAddMembers && selectedConv?.isGroup && isAdminOfSelected && ( -
setShowAddMembers(false)} - > -
e.stopPropagation()} - > -
-

- {t("chat.settings.addMembers")} -

- -
- -
- - setAddMembersSearch(e.target.value)} - placeholder={t("chat.searchUsersPlaceholder")} - className={`bg-white/70 border-slate-200 ${isRtl ? "pr-9" : "pl-9"}`} - /> -
- -
- {t("chat.participantsCount", { count: addMembersIds.length })} -
- - -
- {addableUsers.length === 0 ? ( -
- {t("chat.noUsersFound")} -
- ) : ( - addableUsers.map((u) => { - const checked = addMembersIds.includes(u.id); - const display = - (lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username; - return ( - - ); - }) - )} -
-
- -
- - -
-
-
- )} - - {/* Actions sheet (mute / archive / leave) */} - {showActions && selectedConv && ( -
setShowActions(false)} - > -
e.stopPropagation()} - data-testid="dialog-chat-actions" - > -
-

- {t("chat.actions.title")} -

- -
- - - {selectedConv.isGroup && ( - - )} -
-
- )} - - {/* Leave confirmation */} - {confirmLeave && selectedConv?.isGroup && (() => { - const others = (selectedConv.participants ?? []).filter( - (p) => p.id !== user?.id, - ); - const adminCount = (selectedConv.participants ?? []).filter( - (p) => p.isAdmin, - ).length; - const isSoleAdmin = isAdminOfSelected && adminCount === 1; - const showSuccessor = isSoleAdmin && others.length > 0; - const closeDialog = () => { - setConfirmLeave(false); - setSuccessorChoice("auto"); - }; - return ( -
-
e.stopPropagation()} - data-testid="dialog-confirm-leave" - > -

- {t("chat.actions.leaveConfirmTitle")} -

-

- {t("chat.actions.leaveConfirmBody", { name: convName })} -

- {showSuccessor && ( -
-

- {t("chat.actions.successorTitle")} -

-

- {t("chat.actions.successorHelp")} -

-
- - {others.map((p) => ( - - ))} -
-
- )} -
- - -
-
-
- ); - })()} - - {selectedConvId ? ( - /* Message View */ -
- -
- {messages?.map((msg) => { - if (msg.kind && msg.kind !== "user") { - const text = renderSystemMessage(msg, lang, t); - return ( -
-
- {text} -
-
- ); - } - const isMe = msg.senderId === user?.id; - const senderName = - (lang === "ar" ? msg.sender?.displayNameAr : msg.sender?.displayNameEn) ?? - msg.sender?.username ?? - ""; - return ( -
-
- {!isMe && ( -
{senderName}
- )} -
- {msg.content} -
-
- {formatTime(msg.createdAt, lang, { - hour: "2-digit", - minute: "2-digit", - hour12: user?.clockHour12 ?? false, - })} -
-
-
- ); - })} -
-
- - - {/* Message Input */} -
-
- setMessageText(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), handleSend())} - placeholder={t("chat.typeMessage")} - className="flex-1 bg-white/70 border-slate-200" - /> - -
-
-
- ) : ( - /* Conversation List */ -
- {(() => { - const allConvs = conversations ?? []; - const archivedCount = allConvs.filter((c) => c.isArchived).length; - const visibleConvs = allConvs.filter((c) => - showArchived ? c.isArchived : !c.isArchived, - ); - return ( - <> -
- - -
- {convsLoading ? ( -
- {t("common.loading")} -
- ) : visibleConvs.length === 0 ? ( -
- {showArchived ? ( - <> - - {t("chat.noArchived")} - - ) : ( - <> - - {t("chat.noConversations")} - - )} -
- ) : ( - visibleConvs.map((conv) => { - const name = convDisplayName(conv); - const lastMsg = conv.lastMessage; - const promotionMeta = - lastMsg && lastMsg.kind === "admin_promoted" - ? ((lastMsg.meta ?? {}) as SystemMessageMeta) - : null; - const newAdminName = promotionMeta - ? pickDisplayName(promotionMeta.promoted, lang) - : ""; - const isMeNewAdmin = - !!promotionMeta?.promoted && promotionMeta.promoted.id === user?.id; - - return ( - - ); - }) - )} - - ); - })()} -
- )} -
- ); -} diff --git a/artifacts/tx-os/src/pages/home.tsx b/artifacts/tx-os/src/pages/home.tsx index eb557473..27b6f896 100644 --- a/artifacts/tx-os/src/pages/home.tsx +++ b/artifacts/tx-os/src/pages/home.tsx @@ -28,7 +28,6 @@ import { Globe, Grid2X2, Coffee, - MessageSquare, BellRing, Sparkles, } from "lucide-react"; @@ -501,7 +500,7 @@ export default function HomePage() { ); }; - const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "chat", "notifications", "admin"].includes(a.slug)) ?? []; + const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "notifications", "admin"].includes(a.slug)) ?? []; const initials = (displayName || "?").trim().slice(0, 1).toUpperCase(); @@ -620,12 +619,6 @@ export default function HomePage() { Icon={Coffee} iconClass="bg-amber-100 text-amber-600" /> - { if (!n.isRead) { handleMarkOne(n.id); } - if (n.relatedType === "conversation" && n.relatedId != null) { - setLocation(`/chat?c=${n.relatedId}`); - } }; const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0; diff --git a/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs b/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs index d9e4ff04..f96b6b82 100644 --- a/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs +++ b/artifacts/tx-os/tests/admin-inline-dependency-counts.spec.mjs @@ -132,33 +132,11 @@ async function createNote(userId) { ); } -async function createConversationWithMessage(creatorId) { - const { rows } = await pool.query( - `INSERT INTO conversations (created_by, name_en) VALUES ($1, 'inline counts test') RETURNING id`, - [creatorId], - ); - const conversationId = rows[0].id; - await pool.query( - `INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`, - [conversationId, creatorId], - ); - return conversationId; -} - test.afterAll(async () => { - // Order matters: messages / conversations / notes / service_orders - // must go before users (sender_id and created_by have no ON DELETE - // rule). service_orders must also go before services (service_id is - // ON DELETE RESTRICT). + // Order matters: notes / service_orders must go before users + // (user_id has no ON DELETE rule). service_orders must also go + // before services (service_id is ON DELETE RESTRICT). if (createdUserIds.length > 0) { - await pool.query( - `DELETE FROM messages WHERE sender_id = ANY($1::int[])`, - [createdUserIds], - ); - await pool.query( - `DELETE FROM conversations WHERE created_by = ANY($1::int[])`, - [createdUserIds], - ); await pool.query( `DELETE FROM notes WHERE user_id = ANY($1::int[])`, [createdUserIds], @@ -259,8 +237,6 @@ const COUNT_TEXT = { serviceOrders: "orders", userNotes: "notes", userOrders: "orders", - userConversations: "convos", - userMessages: "messages", }, ar: { appGroups: "مجموعة", @@ -269,8 +245,6 @@ const COUNT_TEXT = { serviceOrders: "طلب", userNotes: "ملاحظة", userOrders: "طلب", - userConversations: "محادثة", - userMessages: "رسالة", }, }; @@ -305,7 +279,6 @@ for (const lang of ["en", "ar"]) { // one so `serviceNoOrders` stays at zero orders and we can assert // its counts row is omitted. await createOrder(userWithDeps.id, serviceWithOrders.id); - await createConversationWithMessage(userWithDeps.id); // ---- Login ---- await setLanguage(page, lang); @@ -401,24 +374,12 @@ for (const lang of ["en", "ar"]) { const userOrdersChip = page.locator( `[data-testid="user-counts-orders-${userWithDeps.id}"]`, ); - const convChip = page.locator( - `[data-testid="user-counts-conversations-${userWithDeps.id}"]`, - ); - const msgChip = page.locator( - `[data-testid="user-counts-messages-${userWithDeps.id}"]`, - ); await expect(notesChip).toBeVisible(); await expect(userOrdersChip).toBeVisible(); - await expect(convChip).toBeVisible(); - await expect(msgChip).toBeVisible(); await expect(notesChip).toContainText("1"); await expect(notesChip).toContainText(labels.userNotes); await expect(userOrdersChip).toContainText("1"); await expect(userOrdersChip).toContainText(labels.userOrders); - await expect(convChip).toContainText("1"); - await expect(convChip).toContainText(labels.userConversations); - await expect(msgChip).toContainText("1"); - await expect(msgChip).toContainText(labels.userMessages); // The bare user: row present (delete button), counts row absent. await expect( diff --git a/artifacts/tx-os/tests/leave-group-successor.spec.mjs b/artifacts/tx-os/tests/leave-group-successor.spec.mjs deleted file mode 100644 index 6e2dee58..00000000 --- a/artifacts/tx-os/tests/leave-group-successor.spec.mjs +++ /dev/null @@ -1,245 +0,0 @@ -// UI test for the leave-group successor picker dialog in chat.tsx. -// -// Mirrors the backend coverage in -// artifacts/api-server/tests/conversations-leave.test.mjs but at the UI layer: -// it drives the actual dialog rendered by artifacts/tx-os/src/pages/chat.tsx -// in a real browser, including: -// - The cancel path (close the dialog -> nothing changes). -// - The chosen-successor path (pick a specific member -> they become admin). -// -// Run with: -// DATABASE_URL=... pnpm --filter @workspace/tx-os exec playwright test -// -// Browser binaries can be installed once with: -// pnpm --filter @workspace/tx-os exec playwright install chromium -// -// The test seeds its own users + group via the database and cleans up after -// itself, so it can run against the live dev environment without polluting it. - -import { test, expect } from "@playwright/test"; -import pg from "pg"; - -const DATABASE_URL = process.env.DATABASE_URL; -if (!DATABASE_URL) { - throw new Error("DATABASE_URL must be set to run the leave-group UI test"); -} - -// Same convention as artifacts/api-server/tests/conversations-leave.test.mjs: -// a precomputed bcrypt hash of the literal password "TestPass123!". -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 = []; - -function uniqueSuffix() { - return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; -} - -async function createUser(prefix) { - const username = `${prefix}_${uniqueSuffix()}`; - const { rows } = await pool.query( - `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) - VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, - [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], - ); - 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, displayName: prefix }; -} - -async function createGroup(creatorId, members) { - const groupName = `Successor Picker Test ${uniqueSuffix()}`; - const { rows } = await pool.query( - `INSERT INTO conversations (name_en, is_group, created_by) - VALUES ($1, true, $2) RETURNING id`, - [groupName, creatorId], - ); - const conversationId = rows[0].id; - createdConversationIds.push(conversationId); - - // Insert participants in deterministic joined_at order so that the auto - // promotion would pick the *first* non-admin member. The test then verifies - // that an explicit successor choice can override that default. - 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, groupName }; -} - -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; -} - -test.afterAll(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(); -}); - -async function loginViaUi(page, username, password) { - await page.goto("/login"); - await page.locator("#username").fill(username); - await page.locator("#password").fill(password); - await Promise.all([ - page.waitForURL((url) => !url.pathname.endsWith("/login"), { - timeout: 15_000, - }), - page.locator('form button[type="submit"]').click(), - ]); -} - -async function openLeaveDialog(page, groupName) { - await page.goto("/chat"); - // Wait for the conversation list to load and click the seeded group. - await page.getByRole("button", { name: new RegExp(groupName) }).first().click(); - // Open the actions sheet, then "Leave group". - await page.locator('[data-testid="button-chat-actions"]').click(); - await expect(page.locator('[data-testid="dialog-chat-actions"]')).toBeVisible(); - await page.locator('[data-testid="button-leave-group"]').click(); - await expect(page.locator('[data-testid="dialog-confirm-leave"]')).toBeVisible(); - await expect(page.locator('[data-testid="successor-chooser"]')).toBeVisible(); -} - -test.describe("Leave group: successor picker dialog", () => { - test("cancel path leaves the group and admin assignments unchanged", async ({ - page, - }) => { - const admin = await createUser("leave_ui_cancel_admin"); - const m1 = await createUser("leave_ui_cancel_m1"); - const m2 = await createUser("leave_ui_cancel_m2"); - const { conversationId, groupName } = await createGroup(admin.id, [ - { userId: admin.id, isAdmin: true }, - { userId: m1.id, isAdmin: false }, - { userId: m2.id, isAdmin: false }, - ]); - - await loginViaUi(page, admin.username, TEST_PASSWORD); - await openLeaveDialog(page, groupName); - - // Auto option is selected by default; both successor options are listed. - await expect( - page.locator('[data-testid="successor-option-auto"] input[type="radio"]'), - ).toBeChecked(); - await expect( - page.locator(`[data-testid="successor-option-${m1.id}"]`), - ).toBeVisible(); - await expect( - page.locator(`[data-testid="successor-option-${m2.id}"]`), - ).toBeVisible(); - - // Click the Cancel button inside the leave dialog. - await page - .locator('[data-testid="dialog-confirm-leave"] button') - .filter({ hasText: /^(Cancel|إلغاء)$/ }) - .click(); - - await expect( - page.locator('[data-testid="dialog-confirm-leave"]'), - ).toBeHidden(); - - // Membership and admin flags must be unchanged after a cancel. - const parts = await getParticipants(conversationId); - expect(parts).toHaveLength(3); - const adminRow = parts.find((p) => p.userId === admin.id); - const m1Row = parts.find((p) => p.userId === m1.id); - const m2Row = parts.find((p) => p.userId === m2.id); - expect(adminRow?.isAdmin).toBe(true); - expect(m1Row?.isAdmin).toBe(false); - expect(m2Row?.isAdmin).toBe(false); - }); - - test("picking a specific successor promotes that member, not the auto pick", async ({ - page, - }) => { - const admin = await createUser("leave_ui_pick_admin"); - const m1 = await createUser("leave_ui_pick_m1"); // would be the auto pick - const m2 = await createUser("leave_ui_pick_m2"); // explicitly chosen - const { conversationId, groupName } = await createGroup(admin.id, [ - { userId: admin.id, isAdmin: true }, - { userId: m1.id, isAdmin: false }, - { userId: m2.id, isAdmin: false }, - ]); - - await loginViaUi(page, admin.username, TEST_PASSWORD); - await openLeaveDialog(page, groupName); - - // Pick the later-joined member (m2) as the successor. - await page - .locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`) - .check(); - await expect( - page.locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`), - ).toBeChecked(); - await expect( - page.locator('[data-testid="successor-option-auto"] input[type="radio"]'), - ).not.toBeChecked(); - - // Confirm the leave. - const leavePromise = page.waitForResponse( - (resp) => - resp.url().includes(`/api/conversations/${conversationId}/leave`) && - resp.request().method() === "POST", - ); - await page.locator('[data-testid="button-confirm-leave"]').click(); - const leaveResp = await leavePromise; - expect(leaveResp.status()).toBe(200); - - // The dialog closes and the user is returned to the conversation list. - await expect( - page.locator('[data-testid="dialog-confirm-leave"]'), - ).toBeHidden(); - - // The chosen successor — not the auto pick — must be the new admin. - const parts = await getParticipants(conversationId); - expect(parts).toHaveLength(2); - expect(parts.find((p) => p.userId === admin.id)).toBeUndefined(); - const m1Row = parts.find((p) => p.userId === m1.id); - const m2Row = parts.find((p) => p.userId === m2.id); - expect(m1Row?.isAdmin).toBe(false); - expect(m2Row?.isAdmin).toBe(true); - }); -}); diff --git a/attached_assets/image_1778582606077.png b/attached_assets/image_1778582606077.png new file mode 100644 index 00000000..29a928ce Binary files /dev/null and b/attached_assets/image_1778582606077.png differ diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 517dcb10..6d3c1cb4 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -197,14 +197,6 @@ Omitted from single-user responses (GET /users/:id, PATCH, etc). admin list endpoint (GET /users). */ orderCount?: number; - /** Number of conversations created by this user. Populated only by the -admin list endpoint (GET /users). - */ - conversationCount?: number; - /** Number of messages authored by this user. Populated only by the -admin list endpoint (GET /users). - */ - messageCount?: number; } export interface UpdateUserBody { @@ -371,8 +363,6 @@ export interface UserDeletionConflict { error: string; noteCount: number; orderCount: number; - conversationCount: number; - messageCount: number; } export interface AppDeletionConflict { @@ -643,96 +633,6 @@ export interface BulkDeleteServiceOrdersResponse { failedIds: number[]; } -export interface ParticipantInfo { - id: number; - username: string; - /** @nullable */ - displayNameAr?: string | null; - /** @nullable */ - displayNameEn?: string | null; - /** @nullable */ - avatarUrl?: string | null; - isAdmin: boolean; -} - -export type MessageWithSenderKind = - (typeof MessageWithSenderKind)[keyof typeof MessageWithSenderKind]; - -export const MessageWithSenderKind = { - user: "user", - group_renamed: "group_renamed", - members_added: "members_added", - member_removed: "member_removed", - member_left: "member_left", - admin_promoted: "admin_promoted", -} as const; - -export type MessageWithSenderMeta = { [key: string]: unknown } | null; - -export interface MessageWithSender { - id: number; - conversationId: number; - senderId: number; - content: string; - kind: MessageWithSenderKind; - meta?: MessageWithSenderMeta; - sender: ParticipantInfo; - createdAt: string; - updatedAt: string; -} - -export interface ConversationWithDetails { - id: number; - /** @nullable */ - nameAr?: string | null; - /** @nullable */ - nameEn?: string | null; - /** @nullable */ - avatarUrl?: string | null; - isGroup: boolean; - createdBy: number; - participants: ParticipantInfo[]; - lastMessage?: MessageWithSender | null; - unreadCount: number; - isMuted: boolean; - isArchived: boolean; - createdAt: string; - updatedAt: string; -} - -export interface CreateConversationBody { - participantIds: number[]; - /** @nullable */ - nameAr?: string | null; - /** @nullable */ - nameEn?: string | null; - /** @nullable */ - avatarUrl?: string | null; - isGroup?: boolean; -} - -export interface UpdateConversationBody { - /** @nullable */ - avatarUrl?: string | null; - /** @nullable */ - nameAr?: string | null; - /** @nullable */ - nameEn?: string | null; -} - -export interface UpdateConversationStateBody { - isMuted?: boolean; - isArchived?: boolean; -} - -export interface AddParticipantsBody { - userIds: number[]; -} - -export interface SendMessageBody { - content: string; -} - export interface Notification { id: number; userId: number; @@ -808,7 +708,6 @@ export interface HomeStats { totalApps: number; totalServices: number; unreadNotifications: number; - unreadMessages: number; totalUsers: number; } @@ -1224,47 +1123,6 @@ export interface UserDependentOrdersPage { nextOffset: number | null; } -export interface UserDependentConversationItem { - id: number; - /** @nullable */ - nameAr: string | null; - /** @nullable */ - nameEn: string | null; - isGroup: boolean; - createdAt: string; -} - -export interface UserDependentConversationsPage { - items: UserDependentConversationItem[]; - totalCount: number; - limit: number; - offset: number; - /** @nullable */ - nextOffset: number | null; -} - -export interface UserDependentMessageItem { - id: number; - content: string; - kind: string; - createdAt: string; - conversationId: number; - /** @nullable */ - conversationNameAr: string | null; - /** @nullable */ - conversationNameEn: string | null; - conversationIsGroup: boolean; -} - -export interface UserDependentMessagesPage { - items: UserDependentMessageItem[]; - totalCount: number; - limit: number; - offset: number; - /** @nullable */ - nextOffset: number | null; -} - export interface AuditLogActor { id: number; username: string; @@ -1381,14 +1239,6 @@ export type DeleteServiceParams = { force?: boolean; }; -export type LeaveConversationBody = { - /** When the leaver is the only admin, optionally name a specific -remaining member to promote. Omit (or send null) to keep the -automatic behavior of promoting the longest-tenured member. - */ - successorId?: number | null; -}; - export type ListUsersParams = { /** * Free-text search across username, email, and display names @@ -1822,27 +1672,3 @@ export type GetAdminUserDependentOrdersParams = { */ offset?: number; }; - -export type GetAdminUserDependentConversationsParams = { - /** - * @minimum 1 - * @maximum 200 - */ - limit?: number; - /** - * @minimum 0 - */ - offset?: number; -}; - -export type GetAdminUserDependentMessagesParams = { - /** - * @minimum 1 - * @maximum 200 - */ - limit?: number; - /** - * @minimum 0 - */ - offset?: number; -}; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index a332c32c..be21d74c 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -18,7 +18,6 @@ import type { import type { AddAppPermissionBody, - AddParticipantsBody, AddUserRoleBody, AdminAppOpensByApp, AdminAppOpensByUser, @@ -38,9 +37,7 @@ import type { AuthUser, BulkDeleteServiceOrdersBody, BulkDeleteServiceOrdersResponse, - ConversationWithDetails, CreateAppBody, - CreateConversationBody, CreateGroupBody, CreateNoteFolderBody, CreateRoleBody, @@ -63,8 +60,6 @@ import type { GetAdminAppOpensByUserParams, GetAdminServiceDependentOrdersParams, GetAdminStatsParams, - GetAdminUserDependentConversationsParams, - GetAdminUserDependentMessagesParams, GetAdminUserDependentNotesParams, GetAdminUserDependentOrdersParams, GetAppPermissionAuditParams, @@ -76,7 +71,6 @@ import type { GroupDetail, HealthStatus, HomeStats, - LeaveConversationBody, ListAuditLogsParams, ListGroupsParams, ListMyNotesAliasParams, @@ -84,7 +78,6 @@ import type { ListReceivedNotesParams, ListUsersParams, LoginBody, - MessageWithSender, MyNote, NoteFolder, NoteReply, @@ -105,7 +98,6 @@ import type { RolePermissionsImpact, RolePermissionsImpactBody, RoleUsage, - SendMessageBody, SendNoteBody, SendNoteResult, SentNote, @@ -119,8 +111,6 @@ import type { UpdateAppSettingsBody, UpdateClockHour12Body, UpdateClockStyleBody, - UpdateConversationBody, - UpdateConversationStateBody, UpdateGroupBody, UpdateLanguageBody, UpdateMyAppOrderBody, @@ -132,8 +122,6 @@ import type { UpdateServiceOrderStatusBody, UpdateUserBody, UserDeletionConflict, - UserDependentConversationsPage, - UserDependentMessagesPage, UserDependentNotesPage, UserDependentOrdersPage, UserProfile, @@ -3345,959 +3333,6 @@ export function useListServiceCategories< return { ...query, queryKey: queryOptions.queryKey }; } -/** - * @summary List conversations for current user - */ -export const getListConversationsUrl = () => { - return `/api/conversations`; -}; - -export const listConversations = async ( - options?: RequestInit, -): Promise => { - return customFetch(getListConversationsUrl(), { - ...options, - method: "GET", - }); -}; - -export const getListConversationsQueryKey = () => { - return [`/api/conversations`] as const; -}; - -export const getListConversationsQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListConversationsQueryKey(); - - const queryFn: QueryFunction< - Awaited> - > = ({ signal }) => listConversations({ signal, ...requestOptions }); - - return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type ListConversationsQueryResult = NonNullable< - Awaited> ->; -export type ListConversationsQueryError = ErrorType; - -/** - * @summary List conversations for current user - */ - -export function useListConversations< - TData = Awaited>, - TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getListConversationsQueryOptions(options); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - return { ...query, queryKey: queryOptions.queryKey }; -} - -/** - * @summary Create a conversation - */ -export const getCreateConversationUrl = () => { - return `/api/conversations`; -}; - -export const createConversation = async ( - createConversationBody: CreateConversationBody, - options?: RequestInit, -): Promise => { - return customFetch(getCreateConversationUrl(), { - ...options, - method: "POST", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(createConversationBody), - }); -}; - -export const getCreateConversationMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext -> => { - const mutationKey = ["createConversation"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { data: BodyType } - > = (props) => { - const { data } = props ?? {}; - - return createConversation(data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type CreateConversationMutationResult = NonNullable< - Awaited> ->; -export type CreateConversationMutationBody = BodyType; -export type CreateConversationMutationError = ErrorType; - -/** - * @summary Create a conversation - */ -export const useCreateConversation = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { data: BodyType }, - TContext -> => { - return useMutation(getCreateConversationMutationOptions(options)); -}; - -/** - * @summary Get conversation by ID - */ -export const getGetConversationUrl = (id: number) => { - return `/api/conversations/${id}`; -}; - -export const getConversation = async ( - id: number, - options?: RequestInit, -): Promise => { - return customFetch(getGetConversationUrl(id), { - ...options, - method: "GET", - }); -}; - -export const getGetConversationQueryKey = (id: number) => { - return [`/api/conversations/${id}`] as const; -}; - -export const getGetConversationQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->( - id: number, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetConversationQueryKey(id); - - const queryFn: QueryFunction>> = ({ - signal, - }) => getConversation(id, { signal, ...requestOptions }); - - return { - queryKey, - queryFn, - enabled: !!id, - ...queryOptions, - } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type GetConversationQueryResult = NonNullable< - Awaited> ->; -export type GetConversationQueryError = ErrorType; - -/** - * @summary Get conversation by ID - */ - -export function useGetConversation< - TData = Awaited>, - TError = ErrorType, ->( - id: number, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getGetConversationQueryOptions(id, options); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - return { ...query, queryKey: queryOptions.queryKey }; -} - -/** - * @summary Update conversation properties (admin only) - */ -export const getUpdateConversationUrl = (id: number) => { - return `/api/conversations/${id}`; -}; - -export const updateConversation = async ( - id: number, - updateConversationBody: UpdateConversationBody, - options?: RequestInit, -): Promise => { - return customFetch(getUpdateConversationUrl(id), { - ...options, - method: "PATCH", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(updateConversationBody), - }); -}; - -export const getUpdateConversationMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - const mutationKey = ["updateConversation"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { id: number; data: BodyType } - > = (props) => { - const { id, data } = props ?? {}; - - return updateConversation(id, data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type UpdateConversationMutationResult = NonNullable< - Awaited> ->; -export type UpdateConversationMutationBody = BodyType; -export type UpdateConversationMutationError = ErrorType; - -/** - * @summary Update conversation properties (admin only) - */ -export const useUpdateConversation = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - return useMutation(getUpdateConversationMutationOptions(options)); -}; - -/** - * @summary Add participants to a group conversation (admin only) - */ -export const getAddConversationParticipantsUrl = (id: number) => { - return `/api/conversations/${id}/participants`; -}; - -export const addConversationParticipants = async ( - id: number, - addParticipantsBody: AddParticipantsBody, - options?: RequestInit, -): Promise => { - return customFetch( - getAddConversationParticipantsUrl(id), - { - ...options, - method: "POST", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(addParticipantsBody), - }, - ); -}; - -export const getAddConversationParticipantsMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - const mutationKey = ["addConversationParticipants"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { id: number; data: BodyType } - > = (props) => { - const { id, data } = props ?? {}; - - return addConversationParticipants(id, data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type AddConversationParticipantsMutationResult = NonNullable< - Awaited> ->; -export type AddConversationParticipantsMutationBody = - BodyType; -export type AddConversationParticipantsMutationError = ErrorType; - -/** - * @summary Add participants to a group conversation (admin only) - */ -export const useAddConversationParticipants = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - return useMutation(getAddConversationParticipantsMutationOptions(options)); -}; - -/** - * @summary Remove a participant from a group conversation (admin only) - */ -export const getRemoveConversationParticipantUrl = ( - id: number, - userId: number, -) => { - return `/api/conversations/${id}/participants/${userId}`; -}; - -export const removeConversationParticipant = async ( - id: number, - userId: number, - options?: RequestInit, -): Promise => { - return customFetch( - getRemoveConversationParticipantUrl(id, userId), - { - ...options, - method: "DELETE", - }, - ); -}; - -export const getRemoveConversationParticipantMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; userId: number }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: number; userId: number }, - TContext -> => { - const mutationKey = ["removeConversationParticipant"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { id: number; userId: number } - > = (props) => { - const { id, userId } = props ?? {}; - - return removeConversationParticipant(id, userId, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type RemoveConversationParticipantMutationResult = NonNullable< - Awaited> ->; - -export type RemoveConversationParticipantMutationError = ErrorType; - -/** - * @summary Remove a participant from a group conversation (admin only) - */ -export const useRemoveConversationParticipant = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; userId: number }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: number; userId: number }, - TContext -> => { - return useMutation(getRemoveConversationParticipantMutationOptions(options)); -}; - -/** - * @summary Update per-user state (mute, archive) - */ -export const getUpdateConversationStateUrl = (id: number) => { - return `/api/conversations/${id}/state`; -}; - -export const updateConversationState = async ( - id: number, - updateConversationStateBody: UpdateConversationStateBody, - options?: RequestInit, -): Promise => { - return customFetch( - getUpdateConversationStateUrl(id), - { - ...options, - method: "PATCH", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(updateConversationStateBody), - }, - ); -}; - -export const getUpdateConversationStateMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - const mutationKey = ["updateConversationState"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { id: number; data: BodyType } - > = (props) => { - const { id, data } = props ?? {}; - - return updateConversationState(id, data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type UpdateConversationStateMutationResult = NonNullable< - Awaited> ->; -export type UpdateConversationStateMutationBody = - BodyType; -export type UpdateConversationStateMutationError = ErrorType; - -/** - * @summary Update per-user state (mute, archive) - */ -export const useUpdateConversationState = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - return useMutation(getUpdateConversationStateMutationOptions(options)); -}; - -/** - * @summary Leave a group conversation - */ -export const getLeaveConversationUrl = (id: number) => { - return `/api/conversations/${id}/leave`; -}; - -export const leaveConversation = async ( - id: number, - leaveConversationBody?: LeaveConversationBody, - options?: RequestInit, -): Promise => { - return customFetch(getLeaveConversationUrl(id), { - ...options, - method: "POST", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(leaveConversationBody), - }); -}; - -export const getLeaveConversationMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - const mutationKey = ["leaveConversation"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { id: number; data: BodyType } - > = (props) => { - const { id, data } = props ?? {}; - - return leaveConversation(id, data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type LeaveConversationMutationResult = NonNullable< - Awaited> ->; -export type LeaveConversationMutationBody = BodyType; -export type LeaveConversationMutationError = ErrorType; - -/** - * @summary Leave a group conversation - */ -export const useLeaveConversation = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - return useMutation(getLeaveConversationMutationOptions(options)); -}; - -/** - * @summary List messages in a conversation - */ -export const getListMessagesUrl = (id: number) => { - return `/api/conversations/${id}/messages`; -}; - -export const listMessages = async ( - id: number, - options?: RequestInit, -): Promise => { - return customFetch(getListMessagesUrl(id), { - ...options, - method: "GET", - }); -}; - -export const getListMessagesQueryKey = (id: number) => { - return [`/api/conversations/${id}/messages`] as const; -}; - -export const getListMessagesQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->( - id: number, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListMessagesQueryKey(id); - - const queryFn: QueryFunction>> = ({ - signal, - }) => listMessages(id, { signal, ...requestOptions }); - - return { - queryKey, - queryFn, - enabled: !!id, - ...queryOptions, - } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type ListMessagesQueryResult = NonNullable< - Awaited> ->; -export type ListMessagesQueryError = ErrorType; - -/** - * @summary List messages in a conversation - */ - -export function useListMessages< - TData = Awaited>, - TError = ErrorType, ->( - id: number, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getListMessagesQueryOptions(id, options); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - return { ...query, queryKey: queryOptions.queryKey }; -} - -/** - * @summary Send a message - */ -export const getSendMessageUrl = (id: number) => { - return `/api/conversations/${id}/messages`; -}; - -export const sendMessage = async ( - id: number, - sendMessageBody: SendMessageBody, - options?: RequestInit, -): Promise => { - return customFetch(getSendMessageUrl(id), { - ...options, - method: "POST", - headers: { "Content-Type": "application/json", ...options?.headers }, - body: JSON.stringify(sendMessageBody), - }); -}; - -export const getSendMessageMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - const mutationKey = ["sendMessage"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { id: number; data: BodyType } - > = (props) => { - const { id, data } = props ?? {}; - - return sendMessage(id, data, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type SendMessageMutationResult = NonNullable< - Awaited> ->; -export type SendMessageMutationBody = BodyType; -export type SendMessageMutationError = ErrorType; - -/** - * @summary Send a message - */ -export const useSendMessage = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: number; data: BodyType }, - TContext -> => { - return useMutation(getSendMessageMutationOptions(options)); -}; - -/** - * @summary Mark all messages in conversation as read - */ -export const getMarkConversationReadUrl = (id: number) => { - return `/api/conversations/${id}/read`; -}; - -export const markConversationRead = async ( - id: number, - options?: RequestInit, -): Promise => { - return customFetch(getMarkConversationReadUrl(id), { - ...options, - method: "POST", - }); -}; - -export const getMarkConversationReadMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number }, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - { id: number }, - TContext -> => { - const mutationKey = ["markConversationRead"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - { id: number } - > = (props) => { - const { id } = props ?? {}; - - return markConversationRead(id, requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type MarkConversationReadMutationResult = NonNullable< - Awaited> ->; - -export type MarkConversationReadMutationError = ErrorType; - -/** - * @summary Mark all messages in conversation as read - */ -export const useMarkConversationRead = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { id: number }, - TContext - >; - request?: SecondParameter; -}): UseMutationResult< - Awaited>, - TError, - { id: number }, - TContext -> => { - return useMutation(getMarkConversationReadMutationOptions(options)); -}; - /** * @summary List notifications for current user */ @@ -9596,249 +8631,3 @@ export function useGetAdminUserDependentOrders< return { ...query, queryKey: queryOptions.queryKey }; } - -/** - * @summary List conversations created by a user - */ -export const getGetAdminUserDependentConversationsUrl = ( - id: number, - params?: GetAdminUserDependentConversationsParams, -) => { - const normalizedParams = new URLSearchParams(); - - Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined) { - normalizedParams.append(key, value === null ? "null" : value.toString()); - } - }); - - const stringifiedParams = normalizedParams.toString(); - - return stringifiedParams.length > 0 - ? `/api/admin/users/${id}/dependents/conversations?${stringifiedParams}` - : `/api/admin/users/${id}/dependents/conversations`; -}; - -export const getAdminUserDependentConversations = async ( - id: number, - params?: GetAdminUserDependentConversationsParams, - options?: RequestInit, -): Promise => { - return customFetch( - getGetAdminUserDependentConversationsUrl(id, params), - { - ...options, - method: "GET", - }, - ); -}; - -export const getGetAdminUserDependentConversationsQueryKey = ( - id: number, - params?: GetAdminUserDependentConversationsParams, -) => { - return [ - `/api/admin/users/${id}/dependents/conversations`, - ...(params ? [params] : []), - ] as const; -}; - -export const getGetAdminUserDependentConversationsQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->( - id: number, - params?: GetAdminUserDependentConversationsParams, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = - queryOptions?.queryKey ?? - getGetAdminUserDependentConversationsQueryKey(id, params); - - const queryFn: QueryFunction< - Awaited> - > = ({ signal }) => - getAdminUserDependentConversations(id, params, { - signal, - ...requestOptions, - }); - - return { - queryKey, - queryFn, - enabled: !!id, - ...queryOptions, - } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type GetAdminUserDependentConversationsQueryResult = NonNullable< - Awaited> ->; -export type GetAdminUserDependentConversationsQueryError = - ErrorType; - -/** - * @summary List conversations created by a user - */ - -export function useGetAdminUserDependentConversations< - TData = Awaited>, - TError = ErrorType, ->( - id: number, - params?: GetAdminUserDependentConversationsParams, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getGetAdminUserDependentConversationsQueryOptions( - id, - params, - options, - ); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - return { ...query, queryKey: queryOptions.queryKey }; -} - -/** - * @summary List messages sent by a user - */ -export const getGetAdminUserDependentMessagesUrl = ( - id: number, - params?: GetAdminUserDependentMessagesParams, -) => { - const normalizedParams = new URLSearchParams(); - - Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined) { - normalizedParams.append(key, value === null ? "null" : value.toString()); - } - }); - - const stringifiedParams = normalizedParams.toString(); - - return stringifiedParams.length > 0 - ? `/api/admin/users/${id}/dependents/messages?${stringifiedParams}` - : `/api/admin/users/${id}/dependents/messages`; -}; - -export const getAdminUserDependentMessages = async ( - id: number, - params?: GetAdminUserDependentMessagesParams, - options?: RequestInit, -): Promise => { - return customFetch( - getGetAdminUserDependentMessagesUrl(id, params), - { - ...options, - method: "GET", - }, - ); -}; - -export const getGetAdminUserDependentMessagesQueryKey = ( - id: number, - params?: GetAdminUserDependentMessagesParams, -) => { - return [ - `/api/admin/users/${id}/dependents/messages`, - ...(params ? [params] : []), - ] as const; -}; - -export const getGetAdminUserDependentMessagesQueryOptions = < - TData = Awaited>, - TError = ErrorType, ->( - id: number, - params?: GetAdminUserDependentMessagesParams, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -) => { - const { query: queryOptions, request: requestOptions } = options ?? {}; - - const queryKey = - queryOptions?.queryKey ?? - getGetAdminUserDependentMessagesQueryKey(id, params); - - const queryFn: QueryFunction< - Awaited> - > = ({ signal }) => - getAdminUserDependentMessages(id, params, { signal, ...requestOptions }); - - return { - queryKey, - queryFn, - enabled: !!id, - ...queryOptions, - } as UseQueryOptions< - Awaited>, - TError, - TData - > & { queryKey: QueryKey }; -}; - -export type GetAdminUserDependentMessagesQueryResult = NonNullable< - Awaited> ->; -export type GetAdminUserDependentMessagesQueryError = ErrorType; - -/** - * @summary List messages sent by a user - */ - -export function useGetAdminUserDependentMessages< - TData = Awaited>, - TError = ErrorType, ->( - id: number, - params?: GetAdminUserDependentMessagesParams, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; - }, -): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getGetAdminUserDependentMessagesQueryOptions( - id, - params, - options, - ); - - const query = useQuery(queryOptions) as UseQueryResult & { - queryKey: QueryKey; - }; - - return { ...query, queryKey: queryOptions.queryKey }; -} diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index f911437c..7773d186 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -18,10 +18,6 @@ tags: description: Internal services (khidmati) - name: orders description: Service orders workflow - - name: conversations - description: Chat conversations - - name: messages - description: Chat messages - name: notifications description: Notifications - name: users @@ -928,254 +924,6 @@ paths: items: $ref: "#/components/schemas/ServiceCategory" - # Conversations - /conversations: - get: - operationId: listConversations - tags: [conversations] - summary: List conversations for current user - responses: - "200": - description: Conversations - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/ConversationWithDetails" - - post: - operationId: createConversation - tags: [conversations] - summary: Create a conversation - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateConversationBody" - responses: - "201": - description: Created - content: - application/json: - schema: - $ref: "#/components/schemas/ConversationWithDetails" - - /conversations/{id}: - get: - operationId: getConversation - tags: [conversations] - summary: Get conversation by ID - parameters: - - name: id - in: path - required: true - schema: - type: integer - responses: - "200": - description: Conversation - content: - application/json: - schema: - $ref: "#/components/schemas/ConversationWithDetails" - - patch: - operationId: updateConversation - tags: [conversations] - summary: Update conversation properties (admin only) - parameters: - - name: id - in: path - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateConversationBody" - responses: - "200": - description: Updated conversation - content: - application/json: - schema: - $ref: "#/components/schemas/ConversationWithDetails" - - /conversations/{id}/participants: - post: - operationId: addConversationParticipants - tags: [conversations] - summary: Add participants to a group conversation (admin only) - parameters: - - name: id - in: path - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AddParticipantsBody" - responses: - "200": - description: Updated conversation - content: - application/json: - schema: - $ref: "#/components/schemas/ConversationWithDetails" - - /conversations/{id}/participants/{userId}: - delete: - operationId: removeConversationParticipant - tags: [conversations] - summary: Remove a participant from a group conversation (admin only) - parameters: - - name: id - in: path - required: true - schema: - type: integer - - name: userId - in: path - required: true - schema: - type: integer - responses: - "200": - description: Updated conversation - content: - application/json: - schema: - $ref: "#/components/schemas/ConversationWithDetails" - - /conversations/{id}/state: - patch: - operationId: updateConversationState - tags: [conversations] - summary: Update per-user state (mute, archive) - parameters: - - name: id - in: path - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateConversationStateBody" - responses: - "200": - description: Updated conversation - content: - application/json: - schema: - $ref: "#/components/schemas/ConversationWithDetails" - - /conversations/{id}/leave: - post: - operationId: leaveConversation - tags: [conversations] - summary: Leave a group conversation - parameters: - - name: id - in: path - required: true - schema: - type: integer - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - successorId: - type: integer - description: | - When the leaver is the only admin, optionally name a specific - remaining member to promote. Omit (or send null) to keep the - automatic behavior of promoting the longest-tenured member. - nullable: true - responses: - "200": - description: Left conversation - content: - application/json: - schema: - $ref: "#/components/schemas/SuccessResponse" - - /conversations/{id}/messages: - get: - operationId: listMessages - tags: [messages] - summary: List messages in a conversation - parameters: - - name: id - in: path - required: true - schema: - type: integer - responses: - "200": - description: Messages - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/MessageWithSender" - - post: - operationId: sendMessage - tags: [messages] - summary: Send a message - parameters: - - name: id - in: path - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SendMessageBody" - responses: - "201": - description: Message sent - content: - application/json: - schema: - $ref: "#/components/schemas/MessageWithSender" - - /conversations/{id}/read: - post: - operationId: markConversationRead - tags: [messages] - summary: Mark all messages in conversation as read - parameters: - - name: id - in: path - required: true - schema: - type: integer - responses: - "200": - description: Marked as read - content: - application/json: - schema: - $ref: "#/components/schemas/SuccessResponse" - # Notifications /notifications: get: @@ -3074,70 +2822,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /admin/users/{id}/dependents/conversations: - get: - operationId: getAdminUserDependentConversations - tags: [users] - summary: List conversations created by a user - parameters: - - in: path - name: id - required: true - schema: { type: integer } - - in: query - name: limit - required: false - schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - - in: query - name: offset - required: false - schema: { type: integer, minimum: 0, default: 0 } - responses: - "200": - description: Paged list of conversations - content: - application/json: - schema: - $ref: "#/components/schemas/UserDependentConversationsPage" - "404": - description: User not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /admin/users/{id}/dependents/messages: - get: - operationId: getAdminUserDependentMessages - tags: [users] - summary: List messages sent by a user - parameters: - - in: path - name: id - required: true - schema: { type: integer } - - in: query - name: limit - required: false - schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - - in: query - name: offset - required: false - schema: { type: integer, minimum: 0, default: 0 } - responses: - "200": - description: Paged list of messages - content: - application/json: - schema: - $ref: "#/components/schemas/UserDependentMessagesPage" - "404": - description: User not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - components: schemas: HealthStatus: @@ -3463,16 +3147,6 @@ components: description: | Number of service orders owned by this user. Populated only by the admin list endpoint (GET /users). - conversationCount: - type: integer - description: | - Number of conversations created by this user. Populated only by the - admin list endpoint (GET /users). - messageCount: - type: integer - description: | - Number of messages authored by this user. Populated only by the - admin list endpoint (GET /users). required: - id - username @@ -3817,16 +3491,10 @@ components: type: integer orderCount: type: integer - conversationCount: - type: integer - messageCount: - type: integer required: - error - noteCount - orderCount - - conversationCount - - messageCount AppDeletionConflict: type: object @@ -4304,161 +3972,6 @@ components: - deletedIds - failedIds - ConversationWithDetails: - type: object - properties: - id: - type: integer - nameAr: - type: ["string", "null"] - nameEn: - type: ["string", "null"] - avatarUrl: - type: ["string", "null"] - isGroup: - type: boolean - createdBy: - type: integer - participants: - type: array - items: - $ref: "#/components/schemas/ParticipantInfo" - lastMessage: - $ref: "#/components/schemas/MessageWithSender" - nullable: true - unreadCount: - type: integer - isMuted: - type: boolean - isArchived: - type: boolean - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - required: - - id - - isGroup - - createdBy - - participants - - unreadCount - - isMuted - - isArchived - - createdAt - - updatedAt - - ParticipantInfo: - type: object - properties: - id: - type: integer - username: - type: string - displayNameAr: - type: ["string", "null"] - displayNameEn: - type: ["string", "null"] - avatarUrl: - type: ["string", "null"] - isAdmin: - type: boolean - required: - - id - - username - - isAdmin - - CreateConversationBody: - type: object - properties: - participantIds: - type: array - items: - type: integer - nameAr: - type: ["string", "null"] - nameEn: - type: ["string", "null"] - avatarUrl: - type: ["string", "null"] - isGroup: - type: boolean - required: - - participantIds - - UpdateConversationBody: - type: object - properties: - avatarUrl: - type: ["string", "null"] - nameAr: - type: ["string", "null"] - nameEn: - type: ["string", "null"] - - UpdateConversationStateBody: - type: object - properties: - isMuted: - type: boolean - isArchived: - type: boolean - - AddParticipantsBody: - type: object - properties: - userIds: - type: array - items: - type: integer - required: - - userIds - - MessageWithSender: - type: object - properties: - id: - type: integer - conversationId: - type: integer - senderId: - type: integer - content: - type: string - kind: - type: string - enum: [user, group_renamed, members_added, member_removed, member_left, admin_promoted] - meta: - type: object - nullable: true - additionalProperties: true - sender: - $ref: "#/components/schemas/ParticipantInfo" - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - required: - - id - - conversationId - - senderId - - content - - kind - - sender - - createdAt - - updatedAt - - SendMessageBody: - type: object - properties: - content: - type: string - required: - - content - Notification: type: object properties: @@ -4579,15 +4092,12 @@ components: type: integer unreadNotifications: type: integer - unreadMessages: - type: integer totalUsers: type: integer required: - totalApps - totalServices - unreadNotifications - - unreadMessages - totalUsers AdminStats: @@ -5179,55 +4689,6 @@ components: nextOffset: { type: ["integer", "null"] } required: [items, totalCount, limit, offset, nextOffset] - UserDependentConversationItem: - type: object - properties: - id: { type: integer } - nameAr: { type: ["string", "null"] } - nameEn: { type: ["string", "null"] } - isGroup: { type: boolean } - createdAt: { type: string, format: date-time } - required: [id, nameAr, nameEn, isGroup, createdAt] - - UserDependentConversationsPage: - type: object - properties: - items: - type: array - items: - $ref: "#/components/schemas/UserDependentConversationItem" - totalCount: { type: integer } - limit: { type: integer } - offset: { type: integer } - nextOffset: { type: ["integer", "null"] } - required: [items, totalCount, limit, offset, nextOffset] - - UserDependentMessageItem: - type: object - properties: - id: { type: integer } - content: { type: string } - kind: { type: string } - createdAt: { type: string, format: date-time } - conversationId: { type: integer } - conversationNameAr: { type: ["string", "null"] } - conversationNameEn: { type: ["string", "null"] } - conversationIsGroup: { type: boolean } - required: [id, content, kind, createdAt, conversationId, conversationNameAr, conversationNameEn, conversationIsGroup] - - UserDependentMessagesPage: - type: object - properties: - items: - type: array - items: - $ref: "#/components/schemas/UserDependentMessageItem" - totalCount: { type: integer } - limit: { type: integer } - offset: { type: integer } - nextOffset: { type: ["integer", "null"] } - required: [items, totalCount, limit, offset, nextOffset] - TopUserItem: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index fb55e7df..80878a3f 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1332,456 +1332,6 @@ export const ListServiceCategoriesResponse = zod.array( ListServiceCategoriesResponseItem, ); -/** - * @summary List conversations for current user - */ -export const ListConversationsResponseItem = zod.object({ - id: zod.number(), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isGroup: zod.boolean(), - createdBy: zod.number(), - participants: zod.array( - zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - ), - lastMessage: zod - .object({ - id: zod.number(), - conversationId: zod.number(), - senderId: zod.number(), - content: zod.string(), - kind: zod.enum([ - "user", - "group_renamed", - "members_added", - "member_removed", - "member_left", - "admin_promoted", - ]), - meta: zod.record(zod.string(), zod.unknown()).nullish(), - sender: zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), - }) - .nullish(), - unreadCount: zod.number(), - isMuted: zod.boolean(), - isArchived: zod.boolean(), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), -}); -export const ListConversationsResponse = zod.array( - ListConversationsResponseItem, -); - -/** - * @summary Create a conversation - */ -export const CreateConversationBody = zod.object({ - participantIds: zod.array(zod.number()), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isGroup: zod.boolean().optional(), -}); - -/** - * @summary Get conversation by ID - */ -export const GetConversationParams = zod.object({ - id: zod.coerce.number(), -}); - -export const GetConversationResponse = zod.object({ - id: zod.number(), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isGroup: zod.boolean(), - createdBy: zod.number(), - participants: zod.array( - zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - ), - lastMessage: zod - .object({ - id: zod.number(), - conversationId: zod.number(), - senderId: zod.number(), - content: zod.string(), - kind: zod.enum([ - "user", - "group_renamed", - "members_added", - "member_removed", - "member_left", - "admin_promoted", - ]), - meta: zod.record(zod.string(), zod.unknown()).nullish(), - sender: zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), - }) - .nullish(), - unreadCount: zod.number(), - isMuted: zod.boolean(), - isArchived: zod.boolean(), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), -}); - -/** - * @summary Update conversation properties (admin only) - */ -export const UpdateConversationParams = zod.object({ - id: zod.coerce.number(), -}); - -export const UpdateConversationBody = zod.object({ - avatarUrl: zod.string().nullish(), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), -}); - -export const UpdateConversationResponse = zod.object({ - id: zod.number(), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isGroup: zod.boolean(), - createdBy: zod.number(), - participants: zod.array( - zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - ), - lastMessage: zod - .object({ - id: zod.number(), - conversationId: zod.number(), - senderId: zod.number(), - content: zod.string(), - kind: zod.enum([ - "user", - "group_renamed", - "members_added", - "member_removed", - "member_left", - "admin_promoted", - ]), - meta: zod.record(zod.string(), zod.unknown()).nullish(), - sender: zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), - }) - .nullish(), - unreadCount: zod.number(), - isMuted: zod.boolean(), - isArchived: zod.boolean(), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), -}); - -/** - * @summary Add participants to a group conversation (admin only) - */ -export const AddConversationParticipantsParams = zod.object({ - id: zod.coerce.number(), -}); - -export const AddConversationParticipantsBody = zod.object({ - userIds: zod.array(zod.number()), -}); - -export const AddConversationParticipantsResponse = zod.object({ - id: zod.number(), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isGroup: zod.boolean(), - createdBy: zod.number(), - participants: zod.array( - zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - ), - lastMessage: zod - .object({ - id: zod.number(), - conversationId: zod.number(), - senderId: zod.number(), - content: zod.string(), - kind: zod.enum([ - "user", - "group_renamed", - "members_added", - "member_removed", - "member_left", - "admin_promoted", - ]), - meta: zod.record(zod.string(), zod.unknown()).nullish(), - sender: zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), - }) - .nullish(), - unreadCount: zod.number(), - isMuted: zod.boolean(), - isArchived: zod.boolean(), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), -}); - -/** - * @summary Remove a participant from a group conversation (admin only) - */ -export const RemoveConversationParticipantParams = zod.object({ - id: zod.coerce.number(), - userId: zod.coerce.number(), -}); - -export const RemoveConversationParticipantResponse = zod.object({ - id: zod.number(), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isGroup: zod.boolean(), - createdBy: zod.number(), - participants: zod.array( - zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - ), - lastMessage: zod - .object({ - id: zod.number(), - conversationId: zod.number(), - senderId: zod.number(), - content: zod.string(), - kind: zod.enum([ - "user", - "group_renamed", - "members_added", - "member_removed", - "member_left", - "admin_promoted", - ]), - meta: zod.record(zod.string(), zod.unknown()).nullish(), - sender: zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), - }) - .nullish(), - unreadCount: zod.number(), - isMuted: zod.boolean(), - isArchived: zod.boolean(), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), -}); - -/** - * @summary Update per-user state (mute, archive) - */ -export const UpdateConversationStateParams = zod.object({ - id: zod.coerce.number(), -}); - -export const UpdateConversationStateBody = zod.object({ - isMuted: zod.boolean().optional(), - isArchived: zod.boolean().optional(), -}); - -export const UpdateConversationStateResponse = zod.object({ - id: zod.number(), - nameAr: zod.string().nullish(), - nameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isGroup: zod.boolean(), - createdBy: zod.number(), - participants: zod.array( - zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - ), - lastMessage: zod - .object({ - id: zod.number(), - conversationId: zod.number(), - senderId: zod.number(), - content: zod.string(), - kind: zod.enum([ - "user", - "group_renamed", - "members_added", - "member_removed", - "member_left", - "admin_promoted", - ]), - meta: zod.record(zod.string(), zod.unknown()).nullish(), - sender: zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), - }) - .nullish(), - unreadCount: zod.number(), - isMuted: zod.boolean(), - isArchived: zod.boolean(), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), -}); - -/** - * @summary Leave a group conversation - */ -export const LeaveConversationParams = zod.object({ - id: zod.coerce.number(), -}); - -export const LeaveConversationBody = zod.object({ - successorId: zod - .number() - .nullish() - .describe( - "When the leaver is the only admin, optionally name a specific\nremaining member to promote. Omit (or send null) to keep the\nautomatic behavior of promoting the longest-tenured member.\n", - ), -}); - -export const LeaveConversationResponse = zod.object({ - success: zod.boolean(), -}); - -/** - * @summary List messages in a conversation - */ -export const ListMessagesParams = zod.object({ - id: zod.coerce.number(), -}); - -export const ListMessagesResponseItem = zod.object({ - id: zod.number(), - conversationId: zod.number(), - senderId: zod.number(), - content: zod.string(), - kind: zod.enum([ - "user", - "group_renamed", - "members_added", - "member_removed", - "member_left", - "admin_promoted", - ]), - meta: zod.record(zod.string(), zod.unknown()).nullish(), - sender: zod.object({ - id: zod.number(), - username: zod.string(), - displayNameAr: zod.string().nullish(), - displayNameEn: zod.string().nullish(), - avatarUrl: zod.string().nullish(), - isAdmin: zod.boolean(), - }), - createdAt: zod.coerce.date(), - updatedAt: zod.coerce.date(), -}); -export const ListMessagesResponse = zod.array(ListMessagesResponseItem); - -/** - * @summary Send a message - */ -export const SendMessageParams = zod.object({ - id: zod.coerce.number(), -}); - -export const SendMessageBody = zod.object({ - content: zod.string(), -}); - -/** - * @summary Mark all messages in conversation as read - */ -export const MarkConversationReadParams = zod.object({ - id: zod.coerce.number(), -}); - -export const MarkConversationReadResponse = zod.object({ - success: zod.boolean(), -}); - /** * @summary List notifications for current user */ @@ -1888,18 +1438,6 @@ export const ListUsersResponseItem = zod.object({ .describe( "Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", ), - conversationCount: zod - .number() - .optional() - .describe( - "Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), - messageCount: zod - .number() - .optional() - .describe( - "Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), }); export const ListUsersResponse = zod.array(ListUsersResponseItem); @@ -1972,18 +1510,6 @@ export const GetUserResponse = zod.object({ .describe( "Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", ), - conversationCount: zod - .number() - .optional() - .describe( - "Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), - messageCount: zod - .number() - .optional() - .describe( - "Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), }); /** @@ -2044,18 +1570,6 @@ export const UpdateUserResponse = zod.object({ .describe( "Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", ), - conversationCount: zod - .number() - .optional() - .describe( - "Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), - messageCount: zod - .number() - .optional() - .describe( - "Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), }); /** @@ -2169,18 +1683,6 @@ export const AddUserRoleResponse = zod.object({ .describe( "Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", ), - conversationCount: zod - .number() - .optional() - .describe( - "Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), - messageCount: zod - .number() - .optional() - .describe( - "Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), }); /** @@ -2231,18 +1733,6 @@ export const RemoveUserRoleResponse = zod.object({ .describe( "Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", ), - conversationCount: zod - .number() - .optional() - .describe( - "Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), - messageCount: zod - .number() - .optional() - .describe( - "Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n", - ), }); /** @@ -2878,7 +2368,6 @@ export const GetHomeStatsResponse = zod.object({ totalApps: zod.number(), totalServices: zod.number(), unreadNotifications: zod.number(), - unreadMessages: zod.number(), totalUsers: zod.number(), }); @@ -3830,88 +3319,3 @@ export const GetAdminUserDependentOrdersResponse = zod.object({ offset: zod.number(), nextOffset: zod.number().nullable(), }); - -/** - * @summary List conversations created by a user - */ -export const GetAdminUserDependentConversationsParams = zod.object({ - id: zod.coerce.number(), -}); - -export const getAdminUserDependentConversationsQueryLimitDefault = 50; -export const getAdminUserDependentConversationsQueryLimitMax = 200; - -export const getAdminUserDependentConversationsQueryOffsetDefault = 0; -export const getAdminUserDependentConversationsQueryOffsetMin = 0; - -export const GetAdminUserDependentConversationsQueryParams = zod.object({ - limit: zod.coerce - .number() - .min(1) - .max(getAdminUserDependentConversationsQueryLimitMax) - .default(getAdminUserDependentConversationsQueryLimitDefault), - offset: zod.coerce - .number() - .min(getAdminUserDependentConversationsQueryOffsetMin) - .default(getAdminUserDependentConversationsQueryOffsetDefault), -}); - -export const GetAdminUserDependentConversationsResponse = zod.object({ - items: zod.array( - zod.object({ - id: zod.number(), - nameAr: zod.string().nullable(), - nameEn: zod.string().nullable(), - isGroup: zod.boolean(), - createdAt: zod.coerce.date(), - }), - ), - totalCount: zod.number(), - limit: zod.number(), - offset: zod.number(), - nextOffset: zod.number().nullable(), -}); - -/** - * @summary List messages sent by a user - */ -export const GetAdminUserDependentMessagesParams = zod.object({ - id: zod.coerce.number(), -}); - -export const getAdminUserDependentMessagesQueryLimitDefault = 50; -export const getAdminUserDependentMessagesQueryLimitMax = 200; - -export const getAdminUserDependentMessagesQueryOffsetDefault = 0; -export const getAdminUserDependentMessagesQueryOffsetMin = 0; - -export const GetAdminUserDependentMessagesQueryParams = zod.object({ - limit: zod.coerce - .number() - .min(1) - .max(getAdminUserDependentMessagesQueryLimitMax) - .default(getAdminUserDependentMessagesQueryLimitDefault), - offset: zod.coerce - .number() - .min(getAdminUserDependentMessagesQueryOffsetMin) - .default(getAdminUserDependentMessagesQueryOffsetDefault), -}); - -export const GetAdminUserDependentMessagesResponse = zod.object({ - items: zod.array( - zod.object({ - id: zod.number(), - content: zod.string(), - kind: zod.string(), - createdAt: zod.coerce.date(), - conversationId: zod.number(), - conversationNameAr: zod.string().nullable(), - conversationNameEn: zod.string().nullable(), - conversationIsGroup: zod.boolean(), - }), - ), - totalCount: zod.number(), - limit: zod.number(), - offset: zod.number(), - nextOffset: zod.number().nullable(), -}); diff --git a/lib/db/src/schema/conversations.ts b/lib/db/src/schema/conversations.ts deleted file mode 100644 index 46db5ef6..00000000 --- a/lib/db/src/schema/conversations.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey, jsonb } from "drizzle-orm/pg-core"; -import { createInsertSchema } from "drizzle-zod"; -import { z } from "zod/v4"; -import { usersTable } from "./users"; - -export const conversationsTable = pgTable("conversations", { - id: serial("id").primaryKey(), - nameAr: varchar("name_ar", { length: 300 }), - nameEn: varchar("name_en", { length: 300 }), - avatarUrl: text("avatar_url"), - isGroup: boolean("is_group").notNull().default(false), - createdBy: integer("created_by").notNull().references(() => usersTable.id), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), -}); - -export const conversationParticipantsTable = pgTable("conversation_participants", { - conversationId: integer("conversation_id").notNull().references(() => conversationsTable.id, { onDelete: "cascade" }), - userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), - joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(), - isAdmin: boolean("is_admin").notNull().default(false), - isMuted: boolean("is_muted").notNull().default(false), - isArchived: boolean("is_archived").notNull().default(false), -}, (t) => [primaryKey({ columns: [t.conversationId, t.userId] })]); - -export const messagesTable = pgTable("messages", { - id: serial("id").primaryKey(), - conversationId: integer("conversation_id").notNull().references(() => conversationsTable.id, { onDelete: "cascade" }), - senderId: integer("sender_id").notNull().references(() => usersTable.id), - content: text("content").notNull(), - kind: varchar("kind", { length: 32 }).notNull().default("user"), - meta: jsonb("meta"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), -}); - -export const messageReadsTable = pgTable("message_reads", { - messageId: integer("message_id").notNull().references(() => messagesTable.id, { onDelete: "cascade" }), - userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), - readAt: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(), -}, (t) => [primaryKey({ columns: [t.messageId, t.userId] })]); - -export const insertMessageSchema = createInsertSchema(messagesTable).omit({ id: true, createdAt: true, updatedAt: true }); -export const insertConversationSchema = createInsertSchema(conversationsTable).omit({ id: true, createdAt: true, updatedAt: true }); -export type InsertMessage = z.infer; -export type Message = typeof messagesTable.$inferSelect; -export type Conversation = typeof conversationsTable.$inferSelect; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 5a6e1566..2381f68d 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -3,7 +3,6 @@ export * from "./roles"; export * from "./apps"; export * from "./services"; export * from "./service-orders"; -export * from "./conversations"; export * from "./notifications"; export * from "./settings"; export * from "./user-app-orders"; diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index 9a8595a1..f037bab2 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -43,7 +43,6 @@ async function main() { { name: "apps:manage", descriptionAr: "إدارة التطبيقات", descriptionEn: "Manage Apps" }, { name: "services:manage", descriptionAr: "إدارة الخدمات", descriptionEn: "Manage Services" }, { name: "users:manage", descriptionAr: "إدارة المستخدمين", descriptionEn: "Manage Users" }, - { name: "chat:access", descriptionAr: "الوصول للمحادثات", descriptionEn: "Access Chat" }, { name: "orders.receive", descriptionAr: "استلام الطلبات", descriptionEn: "Receive service orders" }, ]; @@ -78,17 +77,6 @@ async function main() { .onConflictDoNothing(); } - // Assign chat permission to user role - if (userRoleResolved && allInsertedPerms.length > 0) { - const chatPerm = allInsertedPerms.find((p) => p.name === "chat:access"); - if (chatPerm) { - await db - .insert(rolePermissionsTable) - .values({ roleId: userRoleResolved.id, permissionId: chatPerm.id }) - .onConflictDoNothing(); - } - } - // Assign orders:receive permission to order_receiver role if (orderReceiverResolved && allInsertedPerms.length > 0) { const ordersPerm = allInsertedPerms.find((p) => p.name === "orders.receive"); @@ -170,19 +158,6 @@ async function main() { isSystem: true, sortOrder: 1, }, - { - slug: "chat", - nameAr: "المحادثات", - nameEn: "Chat", - descriptionAr: "تواصل مع زملائك", - descriptionEn: "Connect with your colleagues", - iconName: "MessageCircle", - route: "/chat", - color: "#22c55e", - isActive: true, - isSystem: true, - sortOrder: 2, - }, { slug: "notifications", nameAr: "الإشعارات",