Task #511: Fully remove Chat feature
Destructive removal per user confirmation ("حذف نهائي ما يرجع").
Removed:
- API: routes/conversations.ts, schema/conversations.ts, all chat
socket handlers in src/index.ts, /admin/users/:id/dependents/
conversations+messages endpoints, conversation/message dependency
counts in users/stats routes.
- Web: pages/chat.tsx, /chat route, dock chat filter, MessageSquare
icon and messages StatCard on home, all chat-related UI in
notifications + admin (dependency badges, delete-dialog rows,
UserDependentConversations/Messages sections, count map keys).
- Locales: nav.chat, home.stats.messages, full chat.* block,
admin.deleteUser conv/msgCount, admin.users.counts.conv/msg,
admin.audit.unit.conversation_*/message_*, admin.dependents.user*.
- OpenAPI spec: tags, all /conversations/* paths, conv/msg dependent
paths, related schemas (ConversationWithDetails, MessageWithSender,
UserDependentConversation/MessageItem+Page, etc.), UserProfile and
UserDeletionConflict conv/msg fields, HomeStats.unreadMessages.
Regenerated client via orval.
- Database: dropped message_reads, messages,
conversation_participants, conversations (CASCADE); deleted
notifications with related_type='conversation' or type='chat';
deleted apps row with slug='chat'; ran drizzle push-force.
- Seed: removed chat:access permission + user-role assignment +
seeded chat app entry from scripts/src/seed.ts.
- Tests: deleted conversations-leave.test.mjs; cleaned chat refs from
list-dependency-counts, delete-force-warnings, audit-log-coverage,
and admin-inline-dependency-counts (e2e) — replaced chat dependents
with note dependents where needed for force-delete coverage.
Notes preserved: notes.tsx noConversationsYet/conversationWith refer
to NOTE THREADS (not chat) and were intentionally NOT touched.
executive-meetings.ts not modified per replit.md restriction.
Pre-existing flaky test failures in executive-meetings/group/etc
suites remain unrelated to this task.
This commit is contained in:
@@ -3,11 +3,6 @@ import { Server as SocketIOServer } from "socket.io";
|
|||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
import app, { sessionMiddleware } from "./app";
|
import app, { sessionMiddleware } from "./app";
|
||||||
import { logger } from "./lib/logger";
|
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"];
|
const rawPort = process.env["PORT"];
|
||||||
|
|
||||||
@@ -58,29 +53,6 @@ io.on("connection", (socket) => {
|
|||||||
|
|
||||||
socket.join(`user:${userId}`);
|
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", () => {
|
socket.on("disconnect", () => {
|
||||||
logger.info({ socketId: socket.id }, "Socket disconnected");
|
logger.info({ socketId: socket.id }, "Socket disconnected");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,981 +0,0 @@
|
|||||||
import { Router, type IRouter } from "express";
|
|
||||||
import { eq, and, desc, asc, sql } from "drizzle-orm";
|
|
||||||
import { db } from "@workspace/db";
|
|
||||||
import {
|
|
||||||
conversationsTable,
|
|
||||||
conversationParticipantsTable,
|
|
||||||
messagesTable,
|
|
||||||
messageReadsTable,
|
|
||||||
notificationsTable,
|
|
||||||
usersTable,
|
|
||||||
} from "@workspace/db";
|
|
||||||
import { requireAuth } from "../middlewares/auth";
|
|
||||||
import {
|
|
||||||
CreateConversationBody,
|
|
||||||
GetConversationParams,
|
|
||||||
ListMessagesParams,
|
|
||||||
SendMessageParams,
|
|
||||||
SendMessageBody,
|
|
||||||
MarkConversationReadParams,
|
|
||||||
UpdateConversationBody,
|
|
||||||
UpdateConversationParams,
|
|
||||||
AddConversationParticipantsParams,
|
|
||||||
AddConversationParticipantsBody,
|
|
||||||
RemoveConversationParticipantParams,
|
|
||||||
UpdateConversationStateParams,
|
|
||||||
UpdateConversationStateBody,
|
|
||||||
LeaveConversationBody,
|
|
||||||
LeaveConversationParams,
|
|
||||||
} from "@workspace/api-zod";
|
|
||||||
|
|
||||||
const router: IRouter = Router();
|
|
||||||
|
|
||||||
async function isConversationMember(
|
|
||||||
conversationId: number,
|
|
||||||
userId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const [row] = await db
|
|
||||||
.select({ userId: conversationParticipantsTable.userId })
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
|
||||||
eq(conversationParticipantsTable.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return !!row;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getParticipantState(
|
|
||||||
conversationId: number,
|
|
||||||
userId: number,
|
|
||||||
): Promise<{ isMuted: boolean; isArchived: boolean } | null> {
|
|
||||||
const [row] = await db
|
|
||||||
.select({
|
|
||||||
isMuted: conversationParticipantsTable.isMuted,
|
|
||||||
isArchived: conversationParticipantsTable.isArchived,
|
|
||||||
})
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
|
||||||
eq(conversationParticipantsTable.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildConversationDetails(conversationId: number, currentUserId: number) {
|
|
||||||
const [conv] = await db
|
|
||||||
.select()
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.id, conversationId));
|
|
||||||
|
|
||||||
if (!conv) return null;
|
|
||||||
|
|
||||||
const participants = await db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
username: usersTable.username,
|
|
||||||
displayNameAr: usersTable.displayNameAr,
|
|
||||||
displayNameEn: usersTable.displayNameEn,
|
|
||||||
avatarUrl: usersTable.avatarUrl,
|
|
||||||
isAdmin: conversationParticipantsTable.isAdmin,
|
|
||||||
})
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.innerJoin(usersTable, eq(conversationParticipantsTable.userId, usersTable.id))
|
|
||||||
.where(eq(conversationParticipantsTable.conversationId, conversationId));
|
|
||||||
|
|
||||||
const lastMessages = await db
|
|
||||||
.select()
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(eq(messagesTable.conversationId, conversationId))
|
|
||||||
.orderBy(desc(messagesTable.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let lastMessage = null;
|
|
||||||
if (lastMessages.length > 0) {
|
|
||||||
const msg = lastMessages[0];
|
|
||||||
const [sender] = await db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
username: usersTable.username,
|
|
||||||
displayNameAr: usersTable.displayNameAr,
|
|
||||||
displayNameEn: usersTable.displayNameEn,
|
|
||||||
avatarUrl: usersTable.avatarUrl,
|
|
||||||
isAdmin: sql<boolean>`false`,
|
|
||||||
})
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, msg.senderId));
|
|
||||||
|
|
||||||
lastMessage = { ...msg, sender };
|
|
||||||
}
|
|
||||||
|
|
||||||
const unreadResult = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(messagesTable.conversationId, conversationId),
|
|
||||||
eq(messagesTable.kind, "user"),
|
|
||||||
sql`${messagesTable.id} NOT IN (
|
|
||||||
SELECT message_id FROM message_reads WHERE user_id = ${currentUserId}
|
|
||||||
)`,
|
|
||||||
sql`${messagesTable.senderId} != ${currentUserId}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const unreadCount = Number(unreadResult[0]?.count ?? 0);
|
|
||||||
|
|
||||||
const state = await getParticipantState(conversationId, currentUserId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...conv,
|
|
||||||
participants,
|
|
||||||
lastMessage,
|
|
||||||
unreadCount,
|
|
||||||
isMuted: state?.isMuted ?? false,
|
|
||||||
isArchived: state?.isArchived ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get("/conversations", requireAuth, async (req, res): Promise<void> => {
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
const userConvs = await db
|
|
||||||
.select({ conversationId: conversationParticipantsTable.conversationId })
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(eq(conversationParticipantsTable.userId, userId));
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
userConvs.map((c) => buildConversationDetails(c.conversationId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(results.filter(Boolean));
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/conversations", requireAuth, async (req, res): Promise<void> => {
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
const parsed = CreateConversationBody.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: parsed.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { participantIds, nameAr, nameEn, isGroup, avatarUrl } = parsed.data;
|
|
||||||
|
|
||||||
const allParticipants = [...new Set([userId, ...participantIds])];
|
|
||||||
|
|
||||||
const [conv] = await db
|
|
||||||
.insert(conversationsTable)
|
|
||||||
.values({
|
|
||||||
nameAr: nameAr ?? null,
|
|
||||||
nameEn: nameEn ?? null,
|
|
||||||
avatarUrl: avatarUrl ?? null,
|
|
||||||
isGroup: isGroup ?? false,
|
|
||||||
createdBy: userId,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await db.insert(conversationParticipantsTable).values(
|
|
||||||
allParticipants.map((pid) => ({
|
|
||||||
conversationId: conv.id,
|
|
||||||
userId: pid,
|
|
||||||
isAdmin: pid === userId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const details = await buildConversationDetails(conv.id, userId);
|
|
||||||
res.status(201).json(details);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/conversations/:id", requireAuth, async (req, res): Promise<void> => {
|
|
||||||
const params = GetConversationParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
if (!(await isConversationMember(params.data.id, userId))) {
|
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = await buildConversationDetails(params.data.id, userId);
|
|
||||||
|
|
||||||
if (!details) {
|
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(details);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void> => {
|
|
||||||
const params = UpdateConversationParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = UpdateConversationBody.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: parsed.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
const [participant] = await db
|
|
||||||
.select({ isAdmin: conversationParticipantsTable.isAdmin })
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
|
||||||
eq(conversationParticipantsTable.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!participant) {
|
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!participant.isAdmin) {
|
|
||||||
res.status(403).json({ error: "Only group admins can update this conversation" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [conv] = await db
|
|
||||||
.select({
|
|
||||||
isGroup: conversationsTable.isGroup,
|
|
||||||
nameAr: conversationsTable.nameAr,
|
|
||||||
nameEn: conversationsTable.nameEn,
|
|
||||||
})
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.id, params.data.id));
|
|
||||||
if (!conv) {
|
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!conv.isGroup) {
|
|
||||||
res.status(400).json({ error: "Direct conversations cannot be updated" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: Partial<typeof conversationsTable.$inferInsert> = {};
|
|
||||||
if (parsed.data.avatarUrl !== undefined) {
|
|
||||||
updates.avatarUrl = parsed.data.avatarUrl;
|
|
||||||
}
|
|
||||||
if (parsed.data.nameAr !== undefined) {
|
|
||||||
const trimmed = parsed.data.nameAr?.trim();
|
|
||||||
updates.nameAr = trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
if (parsed.data.nameEn !== undefined) {
|
|
||||||
const trimmed = parsed.data.nameEn?.trim();
|
|
||||||
updates.nameEn = trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalNameAr = updates.nameAr !== undefined ? updates.nameAr : conv.nameAr;
|
|
||||||
const finalNameEn = updates.nameEn !== undefined ? updates.nameEn : conv.nameEn;
|
|
||||||
if (
|
|
||||||
(updates.nameAr !== undefined || updates.nameEn !== undefined) &&
|
|
||||||
!finalNameAr &&
|
|
||||||
!finalNameEn
|
|
||||||
) {
|
|
||||||
res.status(400).json({ error: "Group must have an Arabic or English name" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameChanged =
|
|
||||||
(updates.nameAr !== undefined && updates.nameAr !== conv.nameAr) ||
|
|
||||||
(updates.nameEn !== undefined && updates.nameEn !== conv.nameEn);
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
await db
|
|
||||||
.update(conversationsTable)
|
|
||||||
.set(updates)
|
|
||||||
.where(eq(conversationsTable.id, params.data.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nameChanged) {
|
|
||||||
const actor = await getUserDisplay(userId);
|
|
||||||
await insertAndEmitSystemMessage(params.data.id, userId, "group_renamed", {
|
|
||||||
actor,
|
|
||||||
oldNameAr: conv.nameAr,
|
|
||||||
oldNameEn: conv.nameEn,
|
|
||||||
newNameAr: finalNameAr,
|
|
||||||
newNameEn: finalNameEn,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = await buildConversationDetails(params.data.id, userId);
|
|
||||||
if (!details) {
|
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await emitConversationUpdate(params.data.id);
|
|
||||||
|
|
||||||
res.json(details);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function evictUserFromConversationRoom(
|
|
||||||
userId: number,
|
|
||||||
conversationId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const { io } = await import("../index.js");
|
|
||||||
const sockets = await io.in(`user:${userId}`).fetchSockets();
|
|
||||||
for (const s of sockets) {
|
|
||||||
s.leave(`conversation:${conversationId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemMessageMeta = Record<string, unknown>;
|
|
||||||
|
|
||||||
async function getUserDisplay(userId: number): Promise<{
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
displayNameAr: string | null;
|
|
||||||
displayNameEn: string | null;
|
|
||||||
} | null> {
|
|
||||||
const [u] = await db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
username: usersTable.username,
|
|
||||||
displayNameAr: usersTable.displayNameAr,
|
|
||||||
displayNameEn: usersTable.displayNameEn,
|
|
||||||
})
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, userId));
|
|
||||||
return u ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUsersDisplay(userIds: number[]): Promise<
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
displayNameAr: string | null;
|
|
||||||
displayNameEn: string | null;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
if (userIds.length === 0) return [];
|
|
||||||
return db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
username: usersTable.username,
|
|
||||||
displayNameAr: usersTable.displayNameAr,
|
|
||||||
displayNameEn: usersTable.displayNameEn,
|
|
||||||
})
|
|
||||||
.from(usersTable)
|
|
||||||
.where(sql`${usersTable.id} IN (${sql.join(userIds.map((id) => sql`${id}`), sql`, `)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function insertAndEmitSystemMessage(
|
|
||||||
conversationId: number,
|
|
||||||
actorId: number,
|
|
||||||
kind: string,
|
|
||||||
meta: SystemMessageMeta,
|
|
||||||
): Promise<void> {
|
|
||||||
const [msg] = await db
|
|
||||||
.insert(messagesTable)
|
|
||||||
.values({
|
|
||||||
conversationId,
|
|
||||||
senderId: actorId,
|
|
||||||
content: "",
|
|
||||||
kind,
|
|
||||||
meta,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const [sender] = await db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
username: usersTable.username,
|
|
||||||
displayNameAr: usersTable.displayNameAr,
|
|
||||||
displayNameEn: usersTable.displayNameEn,
|
|
||||||
avatarUrl: usersTable.avatarUrl,
|
|
||||||
isAdmin: sql<boolean>`false`,
|
|
||||||
})
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, actorId));
|
|
||||||
|
|
||||||
const fullMessage = { ...msg, sender };
|
|
||||||
const { io } = await import("../index.js");
|
|
||||||
io.to(`conversation:${conversationId}`).emit("new_message", fullMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emitConversationUpdate(conversationId: number): Promise<void> {
|
|
||||||
const { io } = await import("../index.js");
|
|
||||||
const members = await db
|
|
||||||
.select({ userId: conversationParticipantsTable.userId })
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(eq(conversationParticipantsTable.conversationId, conversationId));
|
|
||||||
|
|
||||||
io.to(`conversation:${conversationId}`).emit("conversation_updated", {
|
|
||||||
conversationId,
|
|
||||||
});
|
|
||||||
for (const m of members) {
|
|
||||||
io.to(`user:${m.userId}`).emit("conversation_updated", { conversationId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requireGroupAdmin(
|
|
||||||
conversationId: number,
|
|
||||||
userId: number,
|
|
||||||
): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
|
||||||
const [participant] = await db
|
|
||||||
.select({ isAdmin: conversationParticipantsTable.isAdmin })
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
|
||||||
eq(conversationParticipantsTable.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!participant) return { ok: false, status: 403, error: "Forbidden" };
|
|
||||||
if (!participant.isAdmin) {
|
|
||||||
return { ok: false, status: 403, error: "Only group admins can manage participants" };
|
|
||||||
}
|
|
||||||
const [conv] = await db
|
|
||||||
.select({ isGroup: conversationsTable.isGroup })
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.id, conversationId));
|
|
||||||
if (!conv) return { ok: false, status: 404, error: "Conversation not found" };
|
|
||||||
if (!conv.isGroup) {
|
|
||||||
return { ok: false, status: 400, error: "Direct conversations cannot be modified" };
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/conversations/:id/participants",
|
|
||||||
requireAuth,
|
|
||||||
async (req, res): Promise<void> => {
|
|
||||||
const params = AddConversationParticipantsParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = AddConversationParticipantsBody.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: parsed.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
const guard = await requireGroupAdmin(params.data.id, userId);
|
|
||||||
if (!guard.ok) {
|
|
||||||
res.status(guard.status).json({ error: guard.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedIds = [...new Set(parsed.data.userIds)].filter((id) => Number.isInteger(id));
|
|
||||||
if (requestedIds.length === 0) {
|
|
||||||
res.status(400).json({ error: "No users to add" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await db
|
|
||||||
.select({ userId: conversationParticipantsTable.userId })
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(eq(conversationParticipantsTable.conversationId, params.data.id));
|
|
||||||
const existingIds = new Set(existing.map((e) => e.userId));
|
|
||||||
const toAdd = requestedIds.filter((id) => !existingIds.has(id));
|
|
||||||
|
|
||||||
if (toAdd.length > 0) {
|
|
||||||
const validUsers = await db
|
|
||||||
.select({ id: usersTable.id })
|
|
||||||
.from(usersTable)
|
|
||||||
.where(sql`${usersTable.id} IN (${sql.join(toAdd.map((id) => sql`${id}`), sql`, `)})`);
|
|
||||||
const validIds = validUsers.map((u) => u.id);
|
|
||||||
if (validIds.length === 0) {
|
|
||||||
res.status(400).json({ error: "No valid users to add" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await db
|
|
||||||
.insert(conversationParticipantsTable)
|
|
||||||
.values(
|
|
||||||
validIds.map((pid) => ({
|
|
||||||
conversationId: params.data.id,
|
|
||||||
userId: pid,
|
|
||||||
isAdmin: false,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
const [actor, addedUsers] = await Promise.all([
|
|
||||||
getUserDisplay(userId),
|
|
||||||
getUsersDisplay(validIds),
|
|
||||||
]);
|
|
||||||
await insertAndEmitSystemMessage(params.data.id, userId, "members_added", {
|
|
||||||
actor,
|
|
||||||
addedUsers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = await buildConversationDetails(params.data.id, userId);
|
|
||||||
if (!details) {
|
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await emitConversationUpdate(params.data.id);
|
|
||||||
res.json(details);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
router.delete(
|
|
||||||
"/conversations/:id/participants/:userId",
|
|
||||||
requireAuth,
|
|
||||||
async (req, res): Promise<void> => {
|
|
||||||
const params = RemoveConversationParticipantParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
const guard = await requireGroupAdmin(params.data.id, userId);
|
|
||||||
if (!guard.ok) {
|
|
||||||
res.status(guard.status).json({ error: guard.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.data.userId === userId) {
|
|
||||||
res.status(400).json({ error: "Admins cannot remove themselves" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await db
|
|
||||||
.delete(conversationParticipantsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
|
||||||
eq(conversationParticipantsTable.userId, params.data.userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning({ userId: conversationParticipantsTable.userId });
|
|
||||||
|
|
||||||
await evictUserFromConversationRoom(params.data.userId, params.data.id);
|
|
||||||
|
|
||||||
if (deleted.length > 0) {
|
|
||||||
const [actor, removedUser] = await Promise.all([
|
|
||||||
getUserDisplay(userId),
|
|
||||||
getUserDisplay(params.data.userId),
|
|
||||||
]);
|
|
||||||
await insertAndEmitSystemMessage(params.data.id, userId, "member_removed", {
|
|
||||||
actor,
|
|
||||||
removedUser,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = await buildConversationDetails(params.data.id, userId);
|
|
||||||
if (!details) {
|
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await emitConversationUpdate(params.data.id);
|
|
||||||
res.json(details);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
router.patch(
|
|
||||||
"/conversations/:id/state",
|
|
||||||
requireAuth,
|
|
||||||
async (req, res): Promise<void> => {
|
|
||||||
const params = UpdateConversationStateParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = UpdateConversationStateBody.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: parsed.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
if (!(await isConversationMember(params.data.id, userId))) {
|
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: { isMuted?: boolean; isArchived?: boolean } = {};
|
|
||||||
if (parsed.data.isMuted !== undefined) updates.isMuted = parsed.data.isMuted;
|
|
||||||
if (parsed.data.isArchived !== undefined) updates.isArchived = parsed.data.isArchived;
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
await db
|
|
||||||
.update(conversationParticipantsTable)
|
|
||||||
.set(updates)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
|
||||||
eq(conversationParticipantsTable.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = await buildConversationDetails(params.data.id, userId);
|
|
||||||
if (!details) {
|
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json(details);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/conversations/:id/leave",
|
|
||||||
requireAuth,
|
|
||||||
async (req, res): Promise<void> => {
|
|
||||||
const params = LeaveConversationParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = LeaveConversationBody.safeParse(req.body ?? {});
|
|
||||||
if (!body.success) {
|
|
||||||
res.status(400).json({ error: body.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const requestedSuccessorId = body.data.successorId ?? null;
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
const [conv] = await db
|
|
||||||
.select({ isGroup: conversationsTable.isGroup })
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.id, params.data.id));
|
|
||||||
if (!conv) {
|
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!conv.isGroup) {
|
|
||||||
res.status(400).json({ error: "Cannot leave a direct conversation" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const participants = await db
|
|
||||||
.select({
|
|
||||||
userId: conversationParticipantsTable.userId,
|
|
||||||
isAdmin: conversationParticipantsTable.isAdmin,
|
|
||||||
joinedAt: conversationParticipantsTable.joinedAt,
|
|
||||||
})
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(eq(conversationParticipantsTable.conversationId, params.data.id))
|
|
||||||
.orderBy(asc(conversationParticipantsTable.joinedAt));
|
|
||||||
|
|
||||||
const leaver = participants.find((p) => p.userId === userId);
|
|
||||||
if (!leaver) {
|
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = participants.filter((p) => p.userId !== userId);
|
|
||||||
|
|
||||||
// If leaver was the only participant, clean up the conversation entirely.
|
|
||||||
if (remaining.length === 0) {
|
|
||||||
await db
|
|
||||||
.delete(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.id, params.data.id));
|
|
||||||
await evictUserFromConversationRoom(userId, params.data.id);
|
|
||||||
res.json({ success: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we need to promote a successor admin.
|
|
||||||
const leaverWasOnlyAdmin =
|
|
||||||
leaver.isAdmin && !remaining.some((p) => p.isAdmin);
|
|
||||||
let successor: (typeof remaining)[number] | null = null;
|
|
||||||
if (leaverWasOnlyAdmin) {
|
|
||||||
if (requestedSuccessorId !== null) {
|
|
||||||
const chosen = remaining.find(
|
|
||||||
(p) => p.userId === requestedSuccessorId,
|
|
||||||
);
|
|
||||||
if (!chosen) {
|
|
||||||
res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Chosen successor is not a member of this group" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
successor = chosen;
|
|
||||||
} else {
|
|
||||||
successor = remaining[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successor) {
|
|
||||||
await db
|
|
||||||
.update(conversationParticipantsTable)
|
|
||||||
.set({ isAdmin: true })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
|
||||||
eq(conversationParticipantsTable.userId, successor.userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Announce the leave (and promotion) before removing the leaver so the
|
|
||||||
// system message has a sensible actor and members see it in real time.
|
|
||||||
const actor = await getUserDisplay(userId);
|
|
||||||
await insertAndEmitSystemMessage(params.data.id, userId, "member_left", {
|
|
||||||
actor,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (successor) {
|
|
||||||
const promoted = await getUserDisplay(successor.userId);
|
|
||||||
await insertAndEmitSystemMessage(
|
|
||||||
params.data.id,
|
|
||||||
successor.userId,
|
|
||||||
"admin_promoted",
|
|
||||||
{ promoted, reason: "previous_admin_left" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(conversationParticipantsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
|
||||||
eq(conversationParticipantsTable.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await evictUserFromConversationRoom(userId, params.data.id);
|
|
||||||
await emitConversationUpdate(params.data.id);
|
|
||||||
res.json({ success: true });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get("/conversations/:id/messages", requireAuth, async (req, res): Promise<void> => {
|
|
||||||
const params = ListMessagesParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
if (!(await isConversationMember(params.data.id, userId))) {
|
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const msgs = await db
|
|
||||||
.select()
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(eq(messagesTable.conversationId, params.data.id))
|
|
||||||
.orderBy(desc(messagesTable.createdAt))
|
|
||||||
.limit(50);
|
|
||||||
|
|
||||||
const messagesWithSenders = await Promise.all(
|
|
||||||
msgs.reverse().map(async (msg) => {
|
|
||||||
const [sender] = await db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
username: usersTable.username,
|
|
||||||
displayNameAr: usersTable.displayNameAr,
|
|
||||||
displayNameEn: usersTable.displayNameEn,
|
|
||||||
avatarUrl: usersTable.avatarUrl,
|
|
||||||
isAdmin: sql<boolean>`false`,
|
|
||||||
})
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, msg.senderId));
|
|
||||||
|
|
||||||
return { ...msg, sender };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(messagesWithSenders);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/conversations/:id/messages", requireAuth, async (req, res): Promise<void> => {
|
|
||||||
const params = SendMessageParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = SendMessageBody.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({ error: parsed.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
if (!(await isConversationMember(params.data.id, userId))) {
|
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [msg] = await db
|
|
||||||
.insert(messagesTable)
|
|
||||||
.values({
|
|
||||||
conversationId: params.data.id,
|
|
||||||
senderId: userId,
|
|
||||||
content: parsed.data.content,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Auto-unarchive for all participants when a new message arrives
|
|
||||||
await db
|
|
||||||
.update(conversationParticipantsTable)
|
|
||||||
.set({ isArchived: false })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
|
||||||
eq(conversationParticipantsTable.isArchived, true),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [sender] = await db
|
|
||||||
.select({
|
|
||||||
id: usersTable.id,
|
|
||||||
username: usersTable.username,
|
|
||||||
displayNameAr: usersTable.displayNameAr,
|
|
||||||
displayNameEn: usersTable.displayNameEn,
|
|
||||||
avatarUrl: usersTable.avatarUrl,
|
|
||||||
isAdmin: sql<boolean>`false`,
|
|
||||||
})
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, userId));
|
|
||||||
|
|
||||||
const fullMessage = { ...msg, sender };
|
|
||||||
|
|
||||||
const { io } = await import("../index.js");
|
|
||||||
io.to(`conversation:${params.data.id}`).emit("new_message", fullMessage);
|
|
||||||
|
|
||||||
await createMessageNotifications({
|
|
||||||
conversationId: params.data.id,
|
|
||||||
senderId: userId,
|
|
||||||
sender,
|
|
||||||
content: parsed.data.content,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(fullMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createMessageNotifications(input: {
|
|
||||||
conversationId: number;
|
|
||||||
senderId: number;
|
|
||||||
sender: {
|
|
||||||
displayNameAr: string | null;
|
|
||||||
displayNameEn: string | null;
|
|
||||||
username: string;
|
|
||||||
};
|
|
||||||
content: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const recipients = await db
|
|
||||||
.select({ userId: conversationParticipantsTable.userId })
|
|
||||||
.from(conversationParticipantsTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversationParticipantsTable.conversationId, input.conversationId),
|
|
||||||
eq(conversationParticipantsTable.isMuted, false),
|
|
||||||
sql`${conversationParticipantsTable.userId} != ${input.senderId}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recipients.length === 0) return;
|
|
||||||
|
|
||||||
const [conv] = await db
|
|
||||||
.select({
|
|
||||||
isGroup: conversationsTable.isGroup,
|
|
||||||
nameAr: conversationsTable.nameAr,
|
|
||||||
nameEn: conversationsTable.nameEn,
|
|
||||||
})
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.id, input.conversationId));
|
|
||||||
if (!conv) return;
|
|
||||||
|
|
||||||
const senderNameAr =
|
|
||||||
input.sender.displayNameAr || input.sender.displayNameEn || input.sender.username;
|
|
||||||
const senderNameEn =
|
|
||||||
input.sender.displayNameEn || input.sender.displayNameAr || input.sender.username;
|
|
||||||
|
|
||||||
const preview = input.content.trim().slice(0, 140);
|
|
||||||
|
|
||||||
let titleAr: string;
|
|
||||||
let titleEn: string;
|
|
||||||
let bodyAr: string | null;
|
|
||||||
let bodyEn: string | null;
|
|
||||||
|
|
||||||
if (conv.isGroup) {
|
|
||||||
const groupNameAr = conv.nameAr || conv.nameEn || senderNameAr;
|
|
||||||
const groupNameEn = conv.nameEn || conv.nameAr || senderNameEn;
|
|
||||||
titleAr = groupNameAr;
|
|
||||||
titleEn = groupNameEn;
|
|
||||||
bodyAr = preview ? `${senderNameAr}: ${preview}` : senderNameAr;
|
|
||||||
bodyEn = preview ? `${senderNameEn}: ${preview}` : senderNameEn;
|
|
||||||
} else {
|
|
||||||
titleAr = senderNameAr;
|
|
||||||
titleEn = senderNameEn;
|
|
||||||
bodyAr = preview || null;
|
|
||||||
bodyEn = preview || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(notificationsTable).values(
|
|
||||||
recipients.map((r) => ({
|
|
||||||
userId: r.userId,
|
|
||||||
titleAr,
|
|
||||||
titleEn,
|
|
||||||
bodyAr,
|
|
||||||
bodyEn,
|
|
||||||
type: "chat",
|
|
||||||
relatedId: input.conversationId,
|
|
||||||
relatedType: "conversation",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { io } = await import("../index.js");
|
|
||||||
for (const r of recipients) {
|
|
||||||
io.to(`user:${r.userId}`).emit("notification_created", {
|
|
||||||
type: "chat",
|
|
||||||
relatedId: input.conversationId,
|
|
||||||
relatedType: "conversation",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post("/conversations/:id/read", requireAuth, async (req, res): Promise<void> => {
|
|
||||||
const params = MarkConversationReadParams.safeParse(req.params);
|
|
||||||
if (!params.success) {
|
|
||||||
res.status(400).json({ error: params.error.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.session.userId!;
|
|
||||||
|
|
||||||
if (!(await isConversationMember(params.data.id, userId))) {
|
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unreadMessages = await db
|
|
||||||
.select({ id: messagesTable.id })
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(messagesTable.conversationId, params.data.id),
|
|
||||||
sql`${messagesTable.id} NOT IN (
|
|
||||||
SELECT message_id FROM message_reads WHERE user_id = ${userId}
|
|
||||||
)`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (unreadMessages.length > 0) {
|
|
||||||
await db.insert(messageReadsTable).values(
|
|
||||||
unreadMessages.map((m) => ({ messageId: m.id, userId })),
|
|
||||||
).onConflictDoNothing();
|
|
||||||
|
|
||||||
const { io } = await import("../index.js");
|
|
||||||
io.to(`conversation:${params.data.id}`).emit("messages_read", {
|
|
||||||
conversationId: params.data.id,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -4,7 +4,6 @@ import authRouter from "./auth";
|
|||||||
import appsRouter from "./apps";
|
import appsRouter from "./apps";
|
||||||
import servicesRouter from "./services";
|
import servicesRouter from "./services";
|
||||||
import serviceOrdersRouter from "./service-orders";
|
import serviceOrdersRouter from "./service-orders";
|
||||||
import conversationsRouter from "./conversations";
|
|
||||||
import notificationsRouter from "./notifications";
|
import notificationsRouter from "./notifications";
|
||||||
import usersRouter from "./users";
|
import usersRouter from "./users";
|
||||||
import statsRouter from "./stats";
|
import statsRouter from "./stats";
|
||||||
@@ -23,7 +22,6 @@ router.use(authRouter);
|
|||||||
router.use(appsRouter);
|
router.use(appsRouter);
|
||||||
router.use(servicesRouter);
|
router.use(servicesRouter);
|
||||||
router.use(serviceOrdersRouter);
|
router.use(serviceOrdersRouter);
|
||||||
router.use(conversationsRouter);
|
|
||||||
router.use(notificationsRouter);
|
router.use(notificationsRouter);
|
||||||
router.use(usersRouter);
|
router.use(usersRouter);
|
||||||
router.use(statsRouter);
|
router.use(statsRouter);
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
servicesTable,
|
servicesTable,
|
||||||
notificationsTable,
|
notificationsTable,
|
||||||
usersTable,
|
usersTable,
|
||||||
messagesTable,
|
|
||||||
messageReadsTable,
|
|
||||||
appOpensTable,
|
appOpensTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||||
@@ -91,23 +89,10 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
|
|||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(usersTable);
|
.from(usersTable);
|
||||||
|
|
||||||
const [unreadMsgCount] = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
sql`${messagesTable.id} NOT IN (
|
|
||||||
SELECT message_id FROM message_reads WHERE user_id = ${userId}
|
|
||||||
)`,
|
|
||||||
sql`${messagesTable.senderId} != ${userId}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalApps: Number(appsCount?.count ?? 0),
|
totalApps: Number(appsCount?.count ?? 0),
|
||||||
totalServices: Number(servicesCount?.count ?? 0),
|
totalServices: Number(servicesCount?.count ?? 0),
|
||||||
unreadNotifications: Number(unreadNotifCount?.count ?? 0),
|
unreadNotifications: Number(unreadNotifCount?.count ?? 0),
|
||||||
unreadMessages: Number(unreadMsgCount?.count ?? 0),
|
|
||||||
totalUsers: Number(usersCount?.count ?? 0),
|
totalUsers: Number(usersCount?.count ?? 0),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
notesTable,
|
notesTable,
|
||||||
serviceOrdersTable,
|
serviceOrdersTable,
|
||||||
servicesTable,
|
servicesTable,
|
||||||
conversationsTable,
|
|
||||||
messagesTable,
|
|
||||||
auditLogsTable,
|
auditLogsTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||||
@@ -74,8 +72,6 @@ export async function getGroupsForUser(userId: number): Promise<GroupSummary[]>
|
|||||||
type DependencyCounts = {
|
type DependencyCounts = {
|
||||||
noteCount: number;
|
noteCount: number;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
conversationCount: number;
|
|
||||||
messageCount: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildUserProfile(
|
function buildUserProfile(
|
||||||
@@ -293,27 +289,9 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
.from(serviceOrdersTable)
|
.from(serviceOrdersTable)
|
||||||
.where(inArray(serviceOrdersTable.userId, userIds))
|
.where(inArray(serviceOrdersTable.userId, userIds))
|
||||||
.groupBy(serviceOrdersTable.userId);
|
.groupBy(serviceOrdersTable.userId);
|
||||||
const convRows = await db
|
|
||||||
.select({
|
|
||||||
userId: conversationsTable.createdBy,
|
|
||||||
count: sql<number>`count(*)::int`,
|
|
||||||
})
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(inArray(conversationsTable.createdBy, userIds))
|
|
||||||
.groupBy(conversationsTable.createdBy);
|
|
||||||
const msgRows = await db
|
|
||||||
.select({
|
|
||||||
userId: messagesTable.senderId,
|
|
||||||
count: sql<number>`count(*)::int`,
|
|
||||||
})
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(inArray(messagesTable.senderId, userIds))
|
|
||||||
.groupBy(messagesTable.senderId);
|
|
||||||
|
|
||||||
const noteMap = new Map(noteRows.map((r) => [r.userId, r.count]));
|
const noteMap = new Map(noteRows.map((r) => [r.userId, r.count]));
|
||||||
const orderMap = new Map(orderRows.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(
|
res.json(
|
||||||
users.map((u) =>
|
users.map((u) =>
|
||||||
@@ -324,8 +302,6 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
{
|
{
|
||||||
noteCount: noteMap.get(u.id) ?? 0,
|
noteCount: noteMap.get(u.id) ?? 0,
|
||||||
orderCount: orderMap.get(u.id) ?? 0,
|
orderCount: orderMap.get(u.id) ?? 0,
|
||||||
conversationCount: convMap.get(u.id) ?? 0,
|
|
||||||
messageCount: msgMap.get(u.id) ?? 0,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -481,87 +457,6 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/admin/users/:id/dependents/conversations",
|
|
||||||
requireAdmin,
|
|
||||||
async (req, res): Promise<void> => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
res.status(400).json({ error: "Invalid id" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(await ensureUserExists(id))) {
|
|
||||||
res.status(404).json({ error: "User not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { limit, offset } = parseUserDependentsPaging(req);
|
|
||||||
const [{ count: totalCount }] = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.createdBy, id));
|
|
||||||
const items = await db
|
|
||||||
.select({
|
|
||||||
id: conversationsTable.id,
|
|
||||||
nameAr: conversationsTable.nameAr,
|
|
||||||
nameEn: conversationsTable.nameEn,
|
|
||||||
isGroup: conversationsTable.isGroup,
|
|
||||||
createdAt: conversationsTable.createdAt,
|
|
||||||
})
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.createdBy, id))
|
|
||||||
.orderBy(sql`${conversationsTable.updatedAt} desc`)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
|
||||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/admin/users/:id/dependents/messages",
|
|
||||||
requireAdmin,
|
|
||||||
async (req, res): Promise<void> => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
res.status(400).json({ error: "Invalid id" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(await ensureUserExists(id))) {
|
|
||||||
res.status(404).json({ error: "User not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { limit, offset } = parseUserDependentsPaging(req);
|
|
||||||
const [{ count: totalCount }] = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(eq(messagesTable.senderId, id));
|
|
||||||
// Join conversation context so the popover can show *where* the user
|
|
||||||
// talked, not just an opaque list of message IDs.
|
|
||||||
const items = await db
|
|
||||||
.select({
|
|
||||||
id: messagesTable.id,
|
|
||||||
content: messagesTable.content,
|
|
||||||
kind: messagesTable.kind,
|
|
||||||
createdAt: messagesTable.createdAt,
|
|
||||||
conversationId: conversationsTable.id,
|
|
||||||
conversationNameAr: conversationsTable.nameAr,
|
|
||||||
conversationNameEn: conversationsTable.nameEn,
|
|
||||||
conversationIsGroup: conversationsTable.isGroup,
|
|
||||||
})
|
|
||||||
.from(messagesTable)
|
|
||||||
.innerJoin(
|
|
||||||
conversationsTable,
|
|
||||||
eq(messagesTable.conversationId, conversationsTable.id),
|
|
||||||
)
|
|
||||||
.where(eq(messagesTable.senderId, id))
|
|
||||||
.orderBy(sql`${messagesTable.createdAt} desc`)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
|
||||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const params = GetUserParams.safeParse(req.params);
|
const params = GetUserParams.safeParse(req.params);
|
||||||
if (!params.success) {
|
if (!params.success) {
|
||||||
@@ -858,51 +753,21 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(serviceOrdersTable)
|
.from(serviceOrdersTable)
|
||||||
.where(eq(serviceOrdersTable.userId, userId));
|
.where(eq(serviceOrdersTable.userId, userId));
|
||||||
const [convRow] = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(conversationsTable)
|
|
||||||
.where(eq(conversationsTable.createdBy, userId));
|
|
||||||
const [msgRow] = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(messagesTable)
|
|
||||||
.where(eq(messagesTable.senderId, userId));
|
|
||||||
|
|
||||||
const noteCount = noteRow?.count ?? 0;
|
const noteCount = noteRow?.count ?? 0;
|
||||||
const orderCount = orderRow?.count ?? 0;
|
const orderCount = orderRow?.count ?? 0;
|
||||||
const conversationCount = convRow?.count ?? 0;
|
const hasDeps = noteCount > 0 || orderCount > 0;
|
||||||
const messageCount = msgRow?.count ?? 0;
|
|
||||||
const hasDeps =
|
|
||||||
noteCount > 0 ||
|
|
||||||
orderCount > 0 ||
|
|
||||||
conversationCount > 0 ||
|
|
||||||
messageCount > 0;
|
|
||||||
|
|
||||||
if (hasDeps && !force) {
|
if (hasDeps && !force) {
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
error: "User has dependent records",
|
error: "User has dependent records",
|
||||||
noteCount,
|
noteCount,
|
||||||
orderCount,
|
orderCount,
|
||||||
conversationCount,
|
|
||||||
messageCount,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
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));
|
await tx.delete(usersTable).where(eq(usersTable.id, userId));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -917,9 +782,7 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
displayNameAr: existing.displayNameAr,
|
displayNameAr: existing.displayNameAr,
|
||||||
email: existing.email,
|
email: existing.email,
|
||||||
force,
|
force,
|
||||||
...(hasDeps
|
...(hasDeps ? { noteCount, orderCount } : {}),
|
||||||
? { noteCount, orderCount, conversationCount, messageCount }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -237,17 +237,6 @@ after(async () => {
|
|||||||
await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = $1`, [
|
await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = $1`, [
|
||||||
uid,
|
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_groups WHERE user_id = $1`, [uid]);
|
||||||
await pool.query(`DELETE FROM user_roles 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]);
|
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.
|
// No-deps path must NOT include the dependency counts.
|
||||||
assert.equal(row.metadata.noteCount, undefined);
|
assert.equal(row.metadata.noteCount, undefined);
|
||||||
assert.equal(row.metadata.orderCount, 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 () => {
|
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");
|
const { id } = await insertUser("u_409");
|
||||||
// Give the user a dependency that blocks delete.
|
// 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(
|
await pool.query(
|
||||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
|
||||||
[conv.rows[0].id, id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/users/${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");
|
const { id, username } = await insertUser("u_f");
|
||||||
|
|
||||||
// Add a dependency so the force branch is taken meaningfully.
|
// 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(
|
await pool.query(
|
||||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
|
||||||
[conv.rows[0].id, id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/users/${id}?force=true`, {
|
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.username, username);
|
||||||
assert.equal(row.metadata.force, true);
|
assert.equal(row.metadata.force, true);
|
||||||
// Force-with-deps path records the counts.
|
// Force-with-deps path records the counts.
|
||||||
assert.equal(typeof row.metadata.conversationCount, "number");
|
assert.equal(typeof row.metadata.noteCount, "number");
|
||||||
assert.equal(typeof row.metadata.messageCount, "number");
|
assert.ok(row.metadata.noteCount >= 1);
|
||||||
assert.ok(row.metadata.conversationCount >= 1);
|
|
||||||
assert.ok(row.metadata.messageCount >= 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- roles ----------
|
// ---------- roles ----------
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
import { test, before, after } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import pg from "pg";
|
|
||||||
|
|
||||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
|
||||||
const DATABASE_URL = process.env.DATABASE_URL;
|
|
||||||
if (!DATABASE_URL) {
|
|
||||||
throw new Error("DATABASE_URL must be set to run these tests");
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEST_PASSWORD = "TestPass123!";
|
|
||||||
const TEST_PASSWORD_HASH =
|
|
||||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
|
||||||
|
|
||||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
||||||
|
|
||||||
const createdUserIds = [];
|
|
||||||
const createdConversationIds = [];
|
|
||||||
|
|
||||||
async function createUser(prefix) {
|
|
||||||
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2, 8)}`;
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
||||||
VALUES ($1, $2, $3, 'Leave Test', 'en', true) RETURNING id`,
|
|
||||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
|
||||||
);
|
|
||||||
const id = rows[0].id;
|
|
||||||
createdUserIds.push(id);
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO user_roles (user_id, role_id)
|
|
||||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
return { id, username };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginAndGetCookie(username, password) {
|
|
||||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
|
||||||
const setCookie = res.headers.get("set-cookie");
|
|
||||||
assert.ok(setCookie, "expected Set-Cookie header from login");
|
|
||||||
const sid = setCookie
|
|
||||||
.split(",")
|
|
||||||
.map((c) => c.split(";")[0].trim())
|
|
||||||
.find((c) => c.startsWith("connect.sid="));
|
|
||||||
assert.ok(sid, "expected connect.sid cookie");
|
|
||||||
return sid;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createGroup(creatorId, members) {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`INSERT INTO conversations (name_en, is_group, created_by)
|
|
||||||
VALUES ('Leave Test Group', true, $1) RETURNING id`,
|
|
||||||
[creatorId],
|
|
||||||
);
|
|
||||||
const conversationId = rows[0].id;
|
|
||||||
createdConversationIds.push(conversationId);
|
|
||||||
|
|
||||||
// Insert participants in a deterministic joined_at order so that auto
|
|
||||||
// promotion picks the expected user.
|
|
||||||
let offsetMs = 0;
|
|
||||||
for (const m of members) {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO conversation_participants
|
|
||||||
(conversation_id, user_id, is_admin, joined_at)
|
|
||||||
VALUES ($1, $2, $3, NOW() - ($4 || ' milliseconds')::interval)`,
|
|
||||||
[conversationId, m.userId, !!m.isAdmin, 10000 - offsetMs],
|
|
||||||
);
|
|
||||||
offsetMs += 1000;
|
|
||||||
}
|
|
||||||
return conversationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getParticipants(conversationId) {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT user_id AS "userId", is_admin AS "isAdmin"
|
|
||||||
FROM conversation_participants
|
|
||||||
WHERE conversation_id = $1
|
|
||||||
ORDER BY joined_at ASC`,
|
|
||||||
[conversationId],
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function conversationExists(conversationId) {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`SELECT 1 FROM conversations WHERE id = $1`,
|
|
||||||
[conversationId],
|
|
||||||
);
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function leave(conversationId, cookie, body) {
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE}/api/conversations/${conversationId}/leave`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Cookie: cookie,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body ?? {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (process.env.LEAVE_TEST_DEBUG && res.status >= 400) {
|
|
||||||
const text = await res.clone().text();
|
|
||||||
console.error(
|
|
||||||
`[leave debug] status=${res.status} cookie=${cookie} body=${text}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
if (createdConversationIds.length > 0) {
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM messages WHERE conversation_id = ANY($1::int[])`,
|
|
||||||
[createdConversationIds],
|
|
||||||
);
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM conversation_participants WHERE conversation_id = ANY($1::int[])`,
|
|
||||||
[createdConversationIds],
|
|
||||||
);
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM conversations WHERE id = ANY($1::int[])`,
|
|
||||||
[createdConversationIds],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (createdUserIds.length > 0) {
|
|
||||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
|
||||||
createdUserIds,
|
|
||||||
]);
|
|
||||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
|
||||||
createdUserIds,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
await pool.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("solo member leaving a group deletes the conversation", async () => {
|
|
||||||
const solo = await createUser("leave_solo");
|
|
||||||
const conv = await createGroup(solo.id, [{ userId: solo.id, isAdmin: true }]);
|
|
||||||
const cookie = await loginAndGetCookie(solo.username, TEST_PASSWORD);
|
|
||||||
|
|
||||||
const res = await leave(conv, cookie);
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
const json = await res.json();
|
|
||||||
assert.equal(json.success, true);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
await conversationExists(conv),
|
|
||||||
false,
|
|
||||||
"conversation should be deleted when last member leaves",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sole admin leaving auto-promotes the earliest remaining member", async () => {
|
|
||||||
const admin = await createUser("leave_admin_auto");
|
|
||||||
const m1 = await createUser("leave_m1");
|
|
||||||
const m2 = await createUser("leave_m2");
|
|
||||||
// admin joined first, then m1, then m2 (handled by createGroup ordering)
|
|
||||||
const conv = await createGroup(admin.id, [
|
|
||||||
{ userId: admin.id, isAdmin: true },
|
|
||||||
{ userId: m1.id, isAdmin: false },
|
|
||||||
{ userId: m2.id, isAdmin: false },
|
|
||||||
]);
|
|
||||||
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
|
|
||||||
|
|
||||||
const res = await leave(conv, cookie);
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
|
|
||||||
const parts = await getParticipants(conv);
|
|
||||||
assert.equal(parts.length, 2);
|
|
||||||
assert.ok(
|
|
||||||
!parts.find((p) => p.userId === admin.id),
|
|
||||||
"leaver must be removed",
|
|
||||||
);
|
|
||||||
const promoted = parts.find((p) => p.isAdmin);
|
|
||||||
assert.ok(promoted, "an admin should have been auto-promoted");
|
|
||||||
assert.equal(
|
|
||||||
promoted.userId,
|
|
||||||
m1.id,
|
|
||||||
"earliest joined remaining member should be promoted",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sole admin leaving with a chosen successor promotes that user", async () => {
|
|
||||||
const admin = await createUser("leave_admin_chosen");
|
|
||||||
const m1 = await createUser("leave_chosen_m1");
|
|
||||||
const m2 = await createUser("leave_chosen_m2");
|
|
||||||
const conv = await createGroup(admin.id, [
|
|
||||||
{ userId: admin.id, isAdmin: true },
|
|
||||||
{ userId: m1.id, isAdmin: false },
|
|
||||||
{ userId: m2.id, isAdmin: false },
|
|
||||||
]);
|
|
||||||
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
|
|
||||||
|
|
||||||
const res = await leave(conv, cookie, { successorId: m2.id });
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
|
|
||||||
const parts = await getParticipants(conv);
|
|
||||||
assert.equal(parts.length, 2);
|
|
||||||
const promoted = parts.find((p) => p.isAdmin);
|
|
||||||
assert.ok(promoted, "an admin should be present after handoff");
|
|
||||||
assert.equal(
|
|
||||||
promoted.userId,
|
|
||||||
m2.id,
|
|
||||||
"the chosen successor should be the new admin",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sole admin leaving with an invalid successor returns 400 and does not leave", async () => {
|
|
||||||
const admin = await createUser("leave_admin_bad");
|
|
||||||
const m1 = await createUser("leave_bad_m1");
|
|
||||||
const outsider = await createUser("leave_bad_outsider");
|
|
||||||
const conv = await createGroup(admin.id, [
|
|
||||||
{ userId: admin.id, isAdmin: true },
|
|
||||||
{ userId: m1.id, isAdmin: false },
|
|
||||||
]);
|
|
||||||
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
|
|
||||||
|
|
||||||
const res = await leave(conv, cookie, { successorId: outsider.id });
|
|
||||||
assert.equal(res.status, 400);
|
|
||||||
|
|
||||||
const parts = await getParticipants(conv);
|
|
||||||
assert.equal(parts.length, 2, "membership should be unchanged");
|
|
||||||
const adminRow = parts.find((p) => p.userId === admin.id);
|
|
||||||
assert.ok(adminRow, "leaver should still be a member");
|
|
||||||
assert.equal(adminRow.isAdmin, true, "leaver should still be admin");
|
|
||||||
const m1Row = parts.find((p) => p.userId === m1.id);
|
|
||||||
assert.equal(m1Row.isAdmin, false, "m1 should not have been promoted");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-admin leaving a group does not promote anyone", async () => {
|
|
||||||
const admin = await createUser("leave_keep_admin");
|
|
||||||
const m1 = await createUser("leave_nonadmin_leaver");
|
|
||||||
const m2 = await createUser("leave_nonadmin_other");
|
|
||||||
const conv = await createGroup(admin.id, [
|
|
||||||
{ userId: admin.id, isAdmin: true },
|
|
||||||
{ userId: m1.id, isAdmin: false },
|
|
||||||
{ userId: m2.id, isAdmin: false },
|
|
||||||
]);
|
|
||||||
const cookie = await loginAndGetCookie(m1.username, TEST_PASSWORD);
|
|
||||||
|
|
||||||
const res = await leave(conv, cookie);
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
|
|
||||||
const parts = await getParticipants(conv);
|
|
||||||
assert.equal(parts.length, 2);
|
|
||||||
assert.ok(
|
|
||||||
!parts.find((p) => p.userId === m1.id),
|
|
||||||
"leaver must be removed",
|
|
||||||
);
|
|
||||||
const admins = parts.filter((p) => p.isAdmin);
|
|
||||||
assert.equal(admins.length, 1, "exactly the original admin remains admin");
|
|
||||||
assert.equal(admins[0].userId, admin.id);
|
|
||||||
const m2Row = parts.find((p) => p.userId === m2.id);
|
|
||||||
assert.equal(m2Row.isAdmin, false, "non-admin member should not be promoted");
|
|
||||||
});
|
|
||||||
@@ -138,10 +138,6 @@ after(async () => {
|
|||||||
}
|
}
|
||||||
for (const uid of [adminId, ...createdUserIds]) {
|
for (const uid of [adminId, ...createdUserIds]) {
|
||||||
if (uid !== undefined) {
|
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 notes WHERE user_id = $1`, [uid]);
|
||||||
await pool.query(`DELETE FROM service_orders 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]);
|
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')`,
|
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'Note A', 'x'), ($1, 'Note B', 'y')`,
|
||||||
[uid],
|
[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}`, {
|
const res = await fetch(`${API_BASE}/api/users/${uid}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -204,8 +191,6 @@ test("DELETE /api/users/:id with dependents returns 409 with counts", async () =
|
|||||||
assert.equal(res.status, 409);
|
assert.equal(res.status, 409);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
assert.equal(body.noteCount, 2);
|
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`, [
|
const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [
|
||||||
uid,
|
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')`, [
|
await pool.query(`INSERT INTO notes (user_id, title) VALUES ($1, 'n')`, [
|
||||||
uid,
|
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`, {
|
const res = await fetch(`${API_BASE}/api/users/${uid}?force=true`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ let depAppId;
|
|||||||
let depServiceId;
|
let depServiceId;
|
||||||
let depGroupId;
|
let depGroupId;
|
||||||
let depPermissionId;
|
let depPermissionId;
|
||||||
let depConvId;
|
|
||||||
|
|
||||||
async function loginAndGetCookie(username, password) {
|
async function loginAndGetCookie(username, password) {
|
||||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
@@ -86,7 +85,7 @@ before(async () => {
|
|||||||
);
|
);
|
||||||
depPermissionId = perm.rows[0].id;
|
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(
|
await pool.query(
|
||||||
`INSERT INTO notes (user_id, content) VALUES ($1, 'note for dep test')`,
|
`INSERT INTO notes (user_id, content) VALUES ($1, 'note for dep test')`,
|
||||||
[depUserId],
|
[depUserId],
|
||||||
@@ -96,20 +95,6 @@ before(async () => {
|
|||||||
VALUES ($1, $2, 'pending', 'dep order')`,
|
VALUES ($1, $2, 'pending', 'dep order')`,
|
||||||
[depServiceId, depUserId],
|
[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.
|
// App dependents: group link, permission restriction, an open record.
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@@ -129,9 +114,6 @@ before(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
after(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 service_orders WHERE user_id = $1`, [depUserId]);
|
||||||
await pool.query(`DELETE FROM notes 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]);
|
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");
|
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`, {
|
const res = await fetch(`${API_BASE}/api/users`, {
|
||||||
headers: { Cookie: adminCookie },
|
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.ok(target, "expected dep user in users list");
|
||||||
assert.equal(target.noteCount, 1, "noteCount should be 1");
|
assert.equal(target.noteCount, 1, "noteCount should be 1");
|
||||||
assert.equal(target.orderCount, 1, "orderCount 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 () => {
|
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.ok(target, "expected clean user in users list");
|
||||||
assert.equal(target.noteCount, 0);
|
assert.equal(target.noteCount, 0);
|
||||||
assert.equal(target.orderCount, 0);
|
assert.equal(target.orderCount, 0);
|
||||||
assert.equal(target.conversationCount, 0);
|
|
||||||
assert.equal(target.messageCount, 0);
|
|
||||||
} finally {
|
} finally {
|
||||||
await pool.query(`DELETE FROM users WHERE id = $1`, [cleanUserId]);
|
await pool.query(`DELETE FROM users WHERE id = $1`, [cleanUserId]);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -16,7 +16,6 @@ import ForgotPasswordPage from "@/pages/forgot-password";
|
|||||||
import ResetPasswordPage from "@/pages/reset-password";
|
import ResetPasswordPage from "@/pages/reset-password";
|
||||||
import HomePage from "@/pages/home";
|
import HomePage from "@/pages/home";
|
||||||
import ServicesPage from "@/pages/services";
|
import ServicesPage from "@/pages/services";
|
||||||
import ChatPage from "@/pages/chat";
|
|
||||||
import NotificationsPage from "@/pages/notifications";
|
import NotificationsPage from "@/pages/notifications";
|
||||||
import AdminPage from "@/pages/admin";
|
import AdminPage from "@/pages/admin";
|
||||||
import NotesPage from "@/pages/notes";
|
import NotesPage from "@/pages/notes";
|
||||||
@@ -53,7 +52,6 @@ function Router() {
|
|||||||
<Route path="/forgot-password" component={ForgotPasswordPage} />
|
<Route path="/forgot-password" component={ForgotPasswordPage} />
|
||||||
<Route path="/reset-password" component={ResetPasswordPage} />
|
<Route path="/reset-password" component={ResetPasswordPage} />
|
||||||
<Route path="/services" component={ServicesPage} />
|
<Route path="/services" component={ServicesPage} />
|
||||||
<Route path="/chat" component={ChatPage} />
|
|
||||||
<Route path="/notifications" component={NotificationsPage} />
|
<Route path="/notifications" component={NotificationsPage} />
|
||||||
<Route path="/admin" component={AdminPage} />
|
<Route path="/admin" component={AdminPage} />
|
||||||
<Route path="/notes" component={NotesPage} />
|
<Route path="/notes" component={NotesPage} />
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"home": "الرئيسية",
|
"home": "الرئيسية",
|
||||||
"services": "الخدمات",
|
"services": "الخدمات",
|
||||||
"chat": "المحادثات",
|
|
||||||
"notifications": "الإشعارات",
|
"notifications": "الإشعارات",
|
||||||
"admin": "لوحة التحكم",
|
"admin": "لوحة التحكم",
|
||||||
"logout": "تسجيل الخروج"
|
"logout": "تسجيل الخروج"
|
||||||
@@ -75,7 +74,6 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"apps": "التطبيقات",
|
"apps": "التطبيقات",
|
||||||
"services": "الخدمات",
|
"services": "الخدمات",
|
||||||
"messages": "الرسائل",
|
|
||||||
"alerts": "التنبيهات"
|
"alerts": "التنبيهات"
|
||||||
},
|
},
|
||||||
"clockStyle": {
|
"clockStyle": {
|
||||||
@@ -206,103 +204,6 @@
|
|||||||
"deleteFailed": "تعذّر حذف الطلبات",
|
"deleteFailed": "تعذّر حذف الطلبات",
|
||||||
"deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي"
|
"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": {
|
"notifSettings": {
|
||||||
"title": "أصوات الإشعارات",
|
"title": "أصوات الإشعارات",
|
||||||
"mute": "كتم الإشعارات",
|
"mute": "كتم الإشعارات",
|
||||||
@@ -381,12 +282,10 @@
|
|||||||
"deleteUser": {
|
"deleteUser": {
|
||||||
"title": "حذف المستخدم \"{{name}}\"؟",
|
"title": "حذف المستخدم \"{{name}}\"؟",
|
||||||
"warning": "هذا المستخدم لديه بيانات. حذفه سيؤثر على ما يلي:",
|
"warning": "هذا المستخدم لديه بيانات. حذفه سيؤثر على ما يلي:",
|
||||||
"forceHint": "سيؤدي ذلك إلى حذف ملاحظاته وطلباته والمحادثات التي أنشأها والرسائل التي أرسلها بشكل نهائي. لا يمكن التراجع عن هذا الإجراء.",
|
"forceHint": "سيؤدي ذلك إلى حذف ملاحظاته وطلباته بشكل نهائي. لا يمكن التراجع عن هذا الإجراء.",
|
||||||
"emptyBody": "هل أنت متأكد من حذف هذا المستخدم؟",
|
"emptyBody": "هل أنت متأكد من حذف هذا المستخدم؟",
|
||||||
"noteCount": "{{count}} ملاحظة",
|
"noteCount": "{{count}} ملاحظة",
|
||||||
"orderCount": "{{count}} طلب",
|
"orderCount": "{{count}} طلب",
|
||||||
"conversationCount": "{{count}} محادثة أنشأها",
|
|
||||||
"messageCount": "{{count}} رسالة أرسلها",
|
|
||||||
"confirm": "حذف",
|
"confirm": "حذف",
|
||||||
"anyway": "حذف رغم ذلك"
|
"anyway": "حذف رغم ذلك"
|
||||||
},
|
},
|
||||||
@@ -460,9 +359,7 @@
|
|||||||
},
|
},
|
||||||
"counts": {
|
"counts": {
|
||||||
"notes": "{{count}} ملاحظة",
|
"notes": "{{count}} ملاحظة",
|
||||||
"orders": "{{count}} طلب",
|
"orders": "{{count}} طلب"
|
||||||
"conversations": "{{count}} محادثة",
|
|
||||||
"messages": "{{count}} رسالة"
|
|
||||||
},
|
},
|
||||||
"historyTitle": "سجل الصلاحيات",
|
"historyTitle": "سجل الصلاحيات",
|
||||||
"historyHint": "الأدوار والمجموعات المسندة لهذا المستخدم. صفِّ بحسب من غيّر أو متى.",
|
"historyHint": "الأدوار والمجموعات المسندة لهذا المستخدم. صفِّ بحسب من غيّر أو متى.",
|
||||||
@@ -796,18 +693,6 @@
|
|||||||
"note_few": "{{count}} ملاحظات",
|
"note_few": "{{count}} ملاحظات",
|
||||||
"note_many": "{{count}} ملاحظةً",
|
"note_many": "{{count}} ملاحظةً",
|
||||||
"note_other": "{{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_zero": "بدون فتحات",
|
||||||
"open_one": "فتحة واحدة",
|
"open_one": "فتحة واحدة",
|
||||||
"open_two": "فتحتان",
|
"open_two": "فتحتان",
|
||||||
@@ -990,18 +875,6 @@
|
|||||||
"title": "الطلبات · {{name}}",
|
"title": "الطلبات · {{name}}",
|
||||||
"subtitle": "{{count}} طلب"
|
"subtitle": "{{count}} طلب"
|
||||||
},
|
},
|
||||||
"userConversations": {
|
|
||||||
"title": "المحادثات · {{name}}",
|
|
||||||
"subtitle": "{{count}} محادثة",
|
|
||||||
"kindGroup": "مجموعة",
|
|
||||||
"kindDirect": "خاصة",
|
|
||||||
"unnamedGroup": "(مجموعة بدون اسم)",
|
|
||||||
"unnamedDirect": "محادثة خاصة"
|
|
||||||
},
|
|
||||||
"userMessages": {
|
|
||||||
"title": "الرسائل · {{name}}",
|
|
||||||
"subtitle": "{{count}} رسالة"
|
|
||||||
},
|
|
||||||
"orderStatus": {
|
"orderStatus": {
|
||||||
"pending": "قيد الانتظار",
|
"pending": "قيد الانتظار",
|
||||||
"received": "مستلم",
|
"received": "مستلم",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"chat": "Chat",
|
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"admin": "Admin Dashboard",
|
"admin": "Admin Dashboard",
|
||||||
"logout": "Logout"
|
"logout": "Logout"
|
||||||
@@ -75,7 +74,6 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"messages": "Messages",
|
|
||||||
"alerts": "Alerts"
|
"alerts": "Alerts"
|
||||||
},
|
},
|
||||||
"clockStyle": {
|
"clockStyle": {
|
||||||
@@ -206,100 +204,6 @@
|
|||||||
"deleteFailed": "Could not delete the orders",
|
"deleteFailed": "Could not delete the orders",
|
||||||
"deletePartial": "Deleted {{deleted}} of {{total}}; the rest could not be deleted"
|
"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": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"markAllRead": "Mark All Read",
|
"markAllRead": "Mark All Read",
|
||||||
@@ -378,12 +282,10 @@
|
|||||||
"deleteUser": {
|
"deleteUser": {
|
||||||
"title": "Delete user \"{{name}}\"?",
|
"title": "Delete user \"{{name}}\"?",
|
||||||
"warning": "This user owns data. Deleting them will affect the following:",
|
"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?",
|
"emptyBody": "Are you sure you want to delete this user?",
|
||||||
"noteCount": "{{count}} note(s)",
|
"noteCount": "{{count}} note(s)",
|
||||||
"orderCount": "{{count}} order(s)",
|
"orderCount": "{{count}} order(s)",
|
||||||
"conversationCount": "{{count}} conversation(s) created",
|
|
||||||
"messageCount": "{{count}} message(s) sent",
|
|
||||||
"confirm": "Delete",
|
"confirm": "Delete",
|
||||||
"anyway": "Delete anyway"
|
"anyway": "Delete anyway"
|
||||||
},
|
},
|
||||||
@@ -457,9 +359,7 @@
|
|||||||
},
|
},
|
||||||
"counts": {
|
"counts": {
|
||||||
"notes": "{{count}} notes",
|
"notes": "{{count}} notes",
|
||||||
"orders": "{{count}} orders",
|
"orders": "{{count}} orders"
|
||||||
"conversations": "{{count}} convos",
|
|
||||||
"messages": "{{count}} messages"
|
|
||||||
},
|
},
|
||||||
"historyTitle": "Permission history",
|
"historyTitle": "Permission history",
|
||||||
"historyHint": "Roles and groups assigned to this user. Filter by who changed it or when.",
|
"historyHint": "Roles and groups assigned to this user. Filter by who changed it or when.",
|
||||||
@@ -709,10 +609,6 @@
|
|||||||
"restriction_other": "{{count}} restrictions",
|
"restriction_other": "{{count}} restrictions",
|
||||||
"note_one": "{{count}} note",
|
"note_one": "{{count}} note",
|
||||||
"note_other": "{{count}} notes",
|
"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_one": "{{count}} open",
|
||||||
"open_other": "{{count}} opens"
|
"open_other": "{{count}} opens"
|
||||||
},
|
},
|
||||||
@@ -891,18 +787,6 @@
|
|||||||
"title": "Orders · {{name}}",
|
"title": "Orders · {{name}}",
|
||||||
"subtitle": "{{count}} orders"
|
"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": {
|
"orderStatus": {
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"received": "Received",
|
"received": "Received",
|
||||||
|
|||||||
@@ -46,18 +46,12 @@ import {
|
|||||||
getGetAdminUserDependentNotesQueryKey,
|
getGetAdminUserDependentNotesQueryKey,
|
||||||
useGetAdminUserDependentOrders,
|
useGetAdminUserDependentOrders,
|
||||||
getGetAdminUserDependentOrdersQueryKey,
|
getGetAdminUserDependentOrdersQueryKey,
|
||||||
useGetAdminUserDependentConversations,
|
|
||||||
getGetAdminUserDependentConversationsQueryKey,
|
|
||||||
useGetAdminUserDependentMessages,
|
|
||||||
getGetAdminUserDependentMessagesQueryKey,
|
|
||||||
getAdminAppDependentGroups,
|
getAdminAppDependentGroups,
|
||||||
getAdminAppDependentRestrictions,
|
getAdminAppDependentRestrictions,
|
||||||
getAdminAppDependentOpens,
|
getAdminAppDependentOpens,
|
||||||
getAdminServiceDependentOrders,
|
getAdminServiceDependentOrders,
|
||||||
getAdminUserDependentNotes,
|
getAdminUserDependentNotes,
|
||||||
getAdminUserDependentOrders,
|
getAdminUserDependentOrders,
|
||||||
getAdminUserDependentConversations,
|
|
||||||
getAdminUserDependentMessages,
|
|
||||||
useAdminIssueResetLink,
|
useAdminIssueResetLink,
|
||||||
useListGroups,
|
useListGroups,
|
||||||
getListGroupsQueryKey,
|
getListGroupsQueryKey,
|
||||||
@@ -130,8 +124,6 @@ import type {
|
|||||||
ServiceDependentOrdersPage,
|
ServiceDependentOrdersPage,
|
||||||
UserDependentNotesPage,
|
UserDependentNotesPage,
|
||||||
UserDependentOrdersPage,
|
UserDependentOrdersPage,
|
||||||
UserDependentConversationsPage,
|
|
||||||
UserDependentMessagesPage,
|
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
@@ -3182,9 +3174,7 @@ type DependencyTarget =
|
|||||||
| { kind: "appOpens"; appId: number; appName: string }
|
| { kind: "appOpens"; appId: number; appName: string }
|
||||||
| { kind: "serviceOrders"; serviceId: number; serviceName: string }
|
| { kind: "serviceOrders"; serviceId: number; serviceName: string }
|
||||||
| { kind: "userNotes"; userId: number; userLabel: string }
|
| { kind: "userNotes"; userId: number; userLabel: string }
|
||||||
| { kind: "userOrders"; userId: number; userLabel: string }
|
| { kind: "userOrders"; userId: number; userLabel: string };
|
||||||
| { kind: "userConversations"; userId: number; userLabel: string }
|
|
||||||
| { kind: "userMessages"; userId: number; userLabel: string };
|
|
||||||
|
|
||||||
type DependencyPage =
|
type DependencyPage =
|
||||||
| AppDependentGroupsPage
|
| AppDependentGroupsPage
|
||||||
@@ -3192,9 +3182,7 @@ type DependencyPage =
|
|||||||
| AppDependentOpensPage
|
| AppDependentOpensPage
|
||||||
| ServiceDependentOrdersPage
|
| ServiceDependentOrdersPage
|
||||||
| UserDependentNotesPage
|
| UserDependentNotesPage
|
||||||
| UserDependentOrdersPage
|
| UserDependentOrdersPage;
|
||||||
| UserDependentConversationsPage
|
|
||||||
| UserDependentMessagesPage;
|
|
||||||
|
|
||||||
const DEPENDENT_PAGE_LIMIT = 50;
|
const DEPENDENT_PAGE_LIMIT = 50;
|
||||||
|
|
||||||
@@ -3314,10 +3302,7 @@ function useDependencyInitial(target: DependencyTarget): {
|
|||||||
: 0;
|
: 0;
|
||||||
const serviceId = target.kind === "serviceOrders" ? target.serviceId : 0;
|
const serviceId = target.kind === "serviceOrders" ? target.serviceId : 0;
|
||||||
const userId =
|
const userId =
|
||||||
target.kind === "userNotes" ||
|
target.kind === "userNotes" || target.kind === "userOrders"
|
||||||
target.kind === "userOrders" ||
|
|
||||||
target.kind === "userConversations" ||
|
|
||||||
target.kind === "userMessages"
|
|
||||||
? target.userId
|
? target.userId
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
@@ -3365,20 +3350,6 @@ function useDependencyInitial(target: DependencyTarget): {
|
|||||||
retry: false,
|
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) {
|
switch (target.kind) {
|
||||||
case "appGroups":
|
case "appGroups":
|
||||||
@@ -3393,10 +3364,6 @@ function useDependencyInitial(target: DependencyTarget): {
|
|||||||
return uNotes;
|
return uNotes;
|
||||||
case "userOrders":
|
case "userOrders":
|
||||||
return uOrders;
|
return uOrders;
|
||||||
case "userConversations":
|
|
||||||
return uConvos;
|
|
||||||
case "userMessages":
|
|
||||||
return uMessages;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3417,10 +3384,6 @@ async function fetchDependentsPage(
|
|||||||
return getAdminUserDependentNotes(target.userId, params);
|
return getAdminUserDependentNotes(target.userId, params);
|
||||||
case "userOrders":
|
case "userOrders":
|
||||||
return getAdminUserDependentOrders(target.userId, params);
|
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 }),
|
title: t("admin.dependents.userOrders.title", { name: target.userLabel }),
|
||||||
subtitle: t("admin.dependents.userOrders.subtitle", { count: totalCount }),
|
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({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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 (
|
|
||||||
<div className={baseRowClass}>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium text-foreground truncate">{name}</div>
|
|
||||||
<div className="text-[11px] text-muted-foreground truncate">
|
|
||||||
{t(
|
|
||||||
c.isGroup
|
|
||||||
? "admin.dependents.userConversations.kindGroup"
|
|
||||||
: "admin.dependents.userConversations.kindDirect",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
|
||||||
{formatOpenTimestamp(c.createdAt, lang)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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 (
|
|
||||||
<div className={baseRowClass + " flex-col items-stretch"}>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="text-[11px] text-muted-foreground truncate">{where}</div>
|
|
||||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
|
||||||
{formatOpenTimestamp(m.createdAt, lang)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-foreground whitespace-pre-wrap break-words">{preview}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3865,18 +3767,6 @@ function UsersPanel({
|
|||||||
target: { kind: "userOrders", userId: u.id, userLabel },
|
target: { kind: "userOrders", userId: u.id, userLabel },
|
||||||
testid: `user-counts-orders-${u.id}`,
|
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;
|
if (parts.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -3966,21 +3856,13 @@ function UsersPanel({
|
|||||||
// to the lazy 409 round-trip if counts are missing.
|
// to the lazy 409 round-trip if counts are missing.
|
||||||
const noteCount = u.noteCount ?? 0;
|
const noteCount = u.noteCount ?? 0;
|
||||||
const orderCount = u.orderCount ?? 0;
|
const orderCount = u.orderCount ?? 0;
|
||||||
const conversationCount = u.conversationCount ?? 0;
|
const hasDeps = noteCount > 0 || orderCount > 0;
|
||||||
const messageCount = u.messageCount ?? 0;
|
|
||||||
const hasDeps =
|
|
||||||
noteCount > 0 ||
|
|
||||||
orderCount > 0 ||
|
|
||||||
conversationCount > 0 ||
|
|
||||||
messageCount > 0;
|
|
||||||
setUserDeleteConflict(
|
setUserDeleteConflict(
|
||||||
hasDeps
|
hasDeps
|
||||||
? {
|
? {
|
||||||
error: "User has dependent records",
|
error: "User has dependent records",
|
||||||
noteCount,
|
noteCount,
|
||||||
orderCount,
|
orderCount,
|
||||||
conversationCount,
|
|
||||||
messageCount,
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
@@ -4098,10 +3980,6 @@ function UsersPanel({
|
|||||||
items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount }));
|
items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount }));
|
||||||
if (userDeleteConflict.orderCount > 0)
|
if (userDeleteConflict.orderCount > 0)
|
||||||
items.push(t("admin.deleteUser.orderCount", { count: userDeleteConflict.orderCount }));
|
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 (
|
return (
|
||||||
<DeletionWarningDialog
|
<DeletionWarningDialog
|
||||||
@@ -6855,9 +6733,7 @@ type AuditTFunction = ReturnType<typeof useTranslation>["t"];
|
|||||||
// `unitLabel`. Keys not in this map are skipped from the inline chip strip.
|
// `unitLabel`. Keys not in this map are skipped from the inline chip strip.
|
||||||
const DEPENDENCY_COUNT_KEYS: Record<string, string> = {
|
const DEPENDENCY_COUNT_KEYS: Record<string, string> = {
|
||||||
orderCount: "order",
|
orderCount: "order",
|
||||||
messageCount: "message",
|
|
||||||
noteCount: "note",
|
noteCount: "note",
|
||||||
conversationCount: "conversation",
|
|
||||||
groupCount: "group",
|
groupCount: "group",
|
||||||
memberCount: "member",
|
memberCount: "member",
|
||||||
appCount: "app",
|
appCount: "app",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Grid2X2,
|
Grid2X2,
|
||||||
Coffee,
|
Coffee,
|
||||||
MessageSquare,
|
|
||||||
BellRing,
|
BellRing,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} 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();
|
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
@@ -620,12 +619,6 @@ export default function HomePage() {
|
|||||||
Icon={Coffee}
|
Icon={Coffee}
|
||||||
iconClass="bg-amber-100 text-amber-600"
|
iconClass="bg-amber-100 text-amber-600"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
|
||||||
label={t("home.stats.messages")}
|
|
||||||
value={formatNumber(stats.unreadMessages, lang)}
|
|
||||||
Icon={MessageSquare}
|
|
||||||
iconClass="bg-emerald-100 text-emerald-600"
|
|
||||||
/>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("home.stats.alerts")}
|
label={t("home.stats.alerts")}
|
||||||
value={formatNumber(stats.unreadNotifications, lang)}
|
value={formatNumber(stats.unreadNotifications, lang)}
|
||||||
|
|||||||
@@ -52,15 +52,10 @@ export default function NotificationsPage() {
|
|||||||
const handleNotificationClick = (n: {
|
const handleNotificationClick = (n: {
|
||||||
id: number;
|
id: number;
|
||||||
isRead: boolean;
|
isRead: boolean;
|
||||||
relatedType?: string | null;
|
|
||||||
relatedId?: number | null;
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!n.isRead) {
|
if (!n.isRead) {
|
||||||
handleMarkOne(n.id);
|
handleMarkOne(n.id);
|
||||||
}
|
}
|
||||||
if (n.relatedType === "conversation" && n.relatedId != null) {
|
|
||||||
setLocation(`/chat?c=${n.relatedId}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0;
|
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0;
|
||||||
|
|||||||
@@ -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 () => {
|
test.afterAll(async () => {
|
||||||
// Order matters: messages / conversations / notes / service_orders
|
// Order matters: notes / service_orders must go before users
|
||||||
// must go before users (sender_id and created_by have no ON DELETE
|
// (user_id has no ON DELETE rule). service_orders must also go
|
||||||
// rule). service_orders must also go before services (service_id is
|
// before services (service_id is ON DELETE RESTRICT).
|
||||||
// ON DELETE RESTRICT).
|
|
||||||
if (createdUserIds.length > 0) {
|
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(
|
await pool.query(
|
||||||
`DELETE FROM notes WHERE user_id = ANY($1::int[])`,
|
`DELETE FROM notes WHERE user_id = ANY($1::int[])`,
|
||||||
[createdUserIds],
|
[createdUserIds],
|
||||||
@@ -259,8 +237,6 @@ const COUNT_TEXT = {
|
|||||||
serviceOrders: "orders",
|
serviceOrders: "orders",
|
||||||
userNotes: "notes",
|
userNotes: "notes",
|
||||||
userOrders: "orders",
|
userOrders: "orders",
|
||||||
userConversations: "convos",
|
|
||||||
userMessages: "messages",
|
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
appGroups: "مجموعة",
|
appGroups: "مجموعة",
|
||||||
@@ -269,8 +245,6 @@ const COUNT_TEXT = {
|
|||||||
serviceOrders: "طلب",
|
serviceOrders: "طلب",
|
||||||
userNotes: "ملاحظة",
|
userNotes: "ملاحظة",
|
||||||
userOrders: "طلب",
|
userOrders: "طلب",
|
||||||
userConversations: "محادثة",
|
|
||||||
userMessages: "رسالة",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,7 +279,6 @@ for (const lang of ["en", "ar"]) {
|
|||||||
// one so `serviceNoOrders` stays at zero orders and we can assert
|
// one so `serviceNoOrders` stays at zero orders and we can assert
|
||||||
// its counts row is omitted.
|
// its counts row is omitted.
|
||||||
await createOrder(userWithDeps.id, serviceWithOrders.id);
|
await createOrder(userWithDeps.id, serviceWithOrders.id);
|
||||||
await createConversationWithMessage(userWithDeps.id);
|
|
||||||
|
|
||||||
// ---- Login ----
|
// ---- Login ----
|
||||||
await setLanguage(page, lang);
|
await setLanguage(page, lang);
|
||||||
@@ -401,24 +374,12 @@ for (const lang of ["en", "ar"]) {
|
|||||||
const userOrdersChip = page.locator(
|
const userOrdersChip = page.locator(
|
||||||
`[data-testid="user-counts-orders-${userWithDeps.id}"]`,
|
`[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(notesChip).toBeVisible();
|
||||||
await expect(userOrdersChip).toBeVisible();
|
await expect(userOrdersChip).toBeVisible();
|
||||||
await expect(convChip).toBeVisible();
|
|
||||||
await expect(msgChip).toBeVisible();
|
|
||||||
await expect(notesChip).toContainText("1");
|
await expect(notesChip).toContainText("1");
|
||||||
await expect(notesChip).toContainText(labels.userNotes);
|
await expect(notesChip).toContainText(labels.userNotes);
|
||||||
await expect(userOrdersChip).toContainText("1");
|
await expect(userOrdersChip).toContainText("1");
|
||||||
await expect(userOrdersChip).toContainText(labels.userOrders);
|
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.
|
// The bare user: row present (delete button), counts row absent.
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
@@ -197,14 +197,6 @@ Omitted from single-user responses (GET /users/:id, PATCH, etc).
|
|||||||
admin list endpoint (GET /users).
|
admin list endpoint (GET /users).
|
||||||
*/
|
*/
|
||||||
orderCount?: number;
|
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 {
|
export interface UpdateUserBody {
|
||||||
@@ -371,8 +363,6 @@ export interface UserDeletionConflict {
|
|||||||
error: string;
|
error: string;
|
||||||
noteCount: number;
|
noteCount: number;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
conversationCount: number;
|
|
||||||
messageCount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppDeletionConflict {
|
export interface AppDeletionConflict {
|
||||||
@@ -643,96 +633,6 @@ export interface BulkDeleteServiceOrdersResponse {
|
|||||||
failedIds: number[];
|
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 {
|
export interface Notification {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -808,7 +708,6 @@ export interface HomeStats {
|
|||||||
totalApps: number;
|
totalApps: number;
|
||||||
totalServices: number;
|
totalServices: number;
|
||||||
unreadNotifications: number;
|
unreadNotifications: number;
|
||||||
unreadMessages: number;
|
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1224,47 +1123,6 @@ export interface UserDependentOrdersPage {
|
|||||||
nextOffset: number | null;
|
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 {
|
export interface AuditLogActor {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -1381,14 +1239,6 @@ export type DeleteServiceParams = {
|
|||||||
force?: boolean;
|
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 = {
|
export type ListUsersParams = {
|
||||||
/**
|
/**
|
||||||
* Free-text search across username, email, and display names
|
* Free-text search across username, email, and display names
|
||||||
@@ -1822,27 +1672,3 @@ export type GetAdminUserDependentOrdersParams = {
|
|||||||
*/
|
*/
|
||||||
offset?: number;
|
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;
|
|
||||||
};
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,6 @@ tags:
|
|||||||
description: Internal services (khidmati)
|
description: Internal services (khidmati)
|
||||||
- name: orders
|
- name: orders
|
||||||
description: Service orders workflow
|
description: Service orders workflow
|
||||||
- name: conversations
|
|
||||||
description: Chat conversations
|
|
||||||
- name: messages
|
|
||||||
description: Chat messages
|
|
||||||
- name: notifications
|
- name: notifications
|
||||||
description: Notifications
|
description: Notifications
|
||||||
- name: users
|
- name: users
|
||||||
@@ -928,254 +924,6 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/ServiceCategory"
|
$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
|
||||||
/notifications:
|
/notifications:
|
||||||
get:
|
get:
|
||||||
@@ -3074,70 +2822,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$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:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
HealthStatus:
|
HealthStatus:
|
||||||
@@ -3463,16 +3147,6 @@ components:
|
|||||||
description: |
|
description: |
|
||||||
Number of service orders owned by this user. Populated only by the
|
Number of service orders owned by this user. Populated only by the
|
||||||
admin list endpoint (GET /users).
|
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:
|
required:
|
||||||
- id
|
- id
|
||||||
- username
|
- username
|
||||||
@@ -3817,16 +3491,10 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
orderCount:
|
orderCount:
|
||||||
type: integer
|
type: integer
|
||||||
conversationCount:
|
|
||||||
type: integer
|
|
||||||
messageCount:
|
|
||||||
type: integer
|
|
||||||
required:
|
required:
|
||||||
- error
|
- error
|
||||||
- noteCount
|
- noteCount
|
||||||
- orderCount
|
- orderCount
|
||||||
- conversationCount
|
|
||||||
- messageCount
|
|
||||||
|
|
||||||
AppDeletionConflict:
|
AppDeletionConflict:
|
||||||
type: object
|
type: object
|
||||||
@@ -4304,161 +3972,6 @@ components:
|
|||||||
- deletedIds
|
- deletedIds
|
||||||
- failedIds
|
- 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:
|
Notification:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -4579,15 +4092,12 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
unreadNotifications:
|
unreadNotifications:
|
||||||
type: integer
|
type: integer
|
||||||
unreadMessages:
|
|
||||||
type: integer
|
|
||||||
totalUsers:
|
totalUsers:
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- totalApps
|
- totalApps
|
||||||
- totalServices
|
- totalServices
|
||||||
- unreadNotifications
|
- unreadNotifications
|
||||||
- unreadMessages
|
|
||||||
- totalUsers
|
- totalUsers
|
||||||
|
|
||||||
AdminStats:
|
AdminStats:
|
||||||
@@ -5179,55 +4689,6 @@ components:
|
|||||||
nextOffset: { type: ["integer", "null"] }
|
nextOffset: { type: ["integer", "null"] }
|
||||||
required: [items, totalCount, limit, offset, nextOffset]
|
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:
|
TopUserItem:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -1332,456 +1332,6 @@ export const ListServiceCategoriesResponse = zod.array(
|
|||||||
ListServiceCategoriesResponseItem,
|
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
|
* @summary List notifications for current user
|
||||||
*/
|
*/
|
||||||
@@ -1888,18 +1438,6 @@ export const ListUsersResponseItem = zod.object({
|
|||||||
.describe(
|
.describe(
|
||||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
"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);
|
export const ListUsersResponse = zod.array(ListUsersResponseItem);
|
||||||
|
|
||||||
@@ -1972,18 +1510,6 @@ export const GetUserResponse = zod.object({
|
|||||||
.describe(
|
.describe(
|
||||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
"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(
|
.describe(
|
||||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
"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(
|
.describe(
|
||||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
"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(
|
.describe(
|
||||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
"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(),
|
totalApps: zod.number(),
|
||||||
totalServices: zod.number(),
|
totalServices: zod.number(),
|
||||||
unreadNotifications: zod.number(),
|
unreadNotifications: zod.number(),
|
||||||
unreadMessages: zod.number(),
|
|
||||||
totalUsers: zod.number(),
|
totalUsers: zod.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3830,88 +3319,3 @@ export const GetAdminUserDependentOrdersResponse = zod.object({
|
|||||||
offset: zod.number(),
|
offset: zod.number(),
|
||||||
nextOffset: zod.number().nullable(),
|
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(),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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<typeof insertMessageSchema>;
|
|
||||||
export type Message = typeof messagesTable.$inferSelect;
|
|
||||||
export type Conversation = typeof conversationsTable.$inferSelect;
|
|
||||||
@@ -3,7 +3,6 @@ export * from "./roles";
|
|||||||
export * from "./apps";
|
export * from "./apps";
|
||||||
export * from "./services";
|
export * from "./services";
|
||||||
export * from "./service-orders";
|
export * from "./service-orders";
|
||||||
export * from "./conversations";
|
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
export * from "./settings";
|
export * from "./settings";
|
||||||
export * from "./user-app-orders";
|
export * from "./user-app-orders";
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ async function main() {
|
|||||||
{ name: "apps:manage", descriptionAr: "إدارة التطبيقات", descriptionEn: "Manage Apps" },
|
{ name: "apps:manage", descriptionAr: "إدارة التطبيقات", descriptionEn: "Manage Apps" },
|
||||||
{ name: "services:manage", descriptionAr: "إدارة الخدمات", descriptionEn: "Manage Services" },
|
{ name: "services:manage", descriptionAr: "إدارة الخدمات", descriptionEn: "Manage Services" },
|
||||||
{ name: "users:manage", descriptionAr: "إدارة المستخدمين", descriptionEn: "Manage Users" },
|
{ name: "users:manage", descriptionAr: "إدارة المستخدمين", descriptionEn: "Manage Users" },
|
||||||
{ name: "chat:access", descriptionAr: "الوصول للمحادثات", descriptionEn: "Access Chat" },
|
|
||||||
{ name: "orders.receive", descriptionAr: "استلام الطلبات", descriptionEn: "Receive service orders" },
|
{ name: "orders.receive", descriptionAr: "استلام الطلبات", descriptionEn: "Receive service orders" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -78,17 +77,6 @@ async function main() {
|
|||||||
.onConflictDoNothing();
|
.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
|
// Assign orders:receive permission to order_receiver role
|
||||||
if (orderReceiverResolved && allInsertedPerms.length > 0) {
|
if (orderReceiverResolved && allInsertedPerms.length > 0) {
|
||||||
const ordersPerm = allInsertedPerms.find((p) => p.name === "orders.receive");
|
const ordersPerm = allInsertedPerms.find((p) => p.name === "orders.receive");
|
||||||
@@ -170,19 +158,6 @@ async function main() {
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
sortOrder: 1,
|
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",
|
slug: "notifications",
|
||||||
nameAr: "الإشعارات",
|
nameAr: "الإشعارات",
|
||||||
|
|||||||
Reference in New Issue
Block a user