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:
riyadhafraa
2026-05-12 10:51:31 +00:00
parent e4ebaffe73
commit 84398de390
27 changed files with 22 additions and 6492 deletions
@@ -237,17 +237,6 @@ after(async () => {
await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = $1`, [
uid,
]);
// Drop messages and conversations the user created/sent — the 409
// user-delete test deliberately seeds these and the FK to users
// is RESTRICT, so they must go before the user row can be removed.
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]);
await pool.query(
`DELETE FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE created_by = $1)`,
[uid],
);
await pool.query(`DELETE FROM conversations WHERE created_by = $1`, [
uid,
]);
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
@@ -305,20 +294,14 @@ test("DELETE /api/users/:id (no-force, no deps) writes one user.delete row with
// No-deps path must NOT include the dependency counts.
assert.equal(row.metadata.noteCount, undefined);
assert.equal(row.metadata.orderCount, undefined);
assert.equal(row.metadata.conversationCount, undefined);
assert.equal(row.metadata.messageCount, undefined);
});
test("DELETE /api/users/:id without force on a user with deps returns 409 and writes NO audit row", async () => {
const { id } = await insertUser("u_409");
// Give the user a dependency that blocks delete.
const conv = await pool.query(
`INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`,
[id],
);
await pool.query(
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
[conv.rows[0].id, id],
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
[id],
);
const res = await fetch(`${API_BASE}/api/users/${id}`, {
@@ -348,13 +331,9 @@ test("DELETE /api/users/:id?force=true writes one user.delete row with force=tru
const { id, username } = await insertUser("u_f");
// Add a dependency so the force branch is taken meaningfully.
const conv = await pool.query(
`INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`,
[id],
);
await pool.query(
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
[conv.rows[0].id, id],
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
[id],
);
const res = await fetch(`${API_BASE}/api/users/${id}?force=true`, {
@@ -374,10 +353,8 @@ test("DELETE /api/users/:id?force=true writes one user.delete row with force=tru
assert.equal(row.metadata.username, username);
assert.equal(row.metadata.force, true);
// Force-with-deps path records the counts.
assert.equal(typeof row.metadata.conversationCount, "number");
assert.equal(typeof row.metadata.messageCount, "number");
assert.ok(row.metadata.conversationCount >= 1);
assert.ok(row.metadata.messageCount >= 1);
assert.equal(typeof row.metadata.noteCount, "number");
assert.ok(row.metadata.noteCount >= 1);
});
// ---------- roles ----------
@@ -1,265 +0,0 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdConversationIds = [];
async function createUser(prefix) {
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Leave Test', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username };
}
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
assert.ok(setCookie, "expected Set-Cookie header from login");
const sid = setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
assert.ok(sid, "expected connect.sid cookie");
return sid;
}
async function createGroup(creatorId, members) {
const { rows } = await pool.query(
`INSERT INTO conversations (name_en, is_group, created_by)
VALUES ('Leave Test Group', true, $1) RETURNING id`,
[creatorId],
);
const conversationId = rows[0].id;
createdConversationIds.push(conversationId);
// Insert participants in a deterministic joined_at order so that auto
// promotion picks the expected user.
let offsetMs = 0;
for (const m of members) {
await pool.query(
`INSERT INTO conversation_participants
(conversation_id, user_id, is_admin, joined_at)
VALUES ($1, $2, $3, NOW() - ($4 || ' milliseconds')::interval)`,
[conversationId, m.userId, !!m.isAdmin, 10000 - offsetMs],
);
offsetMs += 1000;
}
return conversationId;
}
async function getParticipants(conversationId) {
const { rows } = await pool.query(
`SELECT user_id AS "userId", is_admin AS "isAdmin"
FROM conversation_participants
WHERE conversation_id = $1
ORDER BY joined_at ASC`,
[conversationId],
);
return rows;
}
async function conversationExists(conversationId) {
const { rows } = await pool.query(
`SELECT 1 FROM conversations WHERE id = $1`,
[conversationId],
);
return rows.length > 0;
}
async function leave(conversationId, cookie, body) {
const res = await fetch(
`${API_BASE}/api/conversations/${conversationId}/leave`,
{
method: "POST",
headers: {
Cookie: cookie,
"Content-Type": "application/json",
},
body: JSON.stringify(body ?? {}),
},
);
if (process.env.LEAVE_TEST_DEBUG && res.status >= 400) {
const text = await res.clone().text();
console.error(
`[leave debug] status=${res.status} cookie=${cookie} body=${text}`,
);
}
return res;
}
after(async () => {
if (createdConversationIds.length > 0) {
await pool.query(
`DELETE FROM messages WHERE conversation_id = ANY($1::int[])`,
[createdConversationIds],
);
await pool.query(
`DELETE FROM conversation_participants WHERE conversation_id = ANY($1::int[])`,
[createdConversationIds],
);
await pool.query(
`DELETE FROM conversations WHERE id = ANY($1::int[])`,
[createdConversationIds],
);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
test("solo member leaving a group deletes the conversation", async () => {
const solo = await createUser("leave_solo");
const conv = await createGroup(solo.id, [{ userId: solo.id, isAdmin: true }]);
const cookie = await loginAndGetCookie(solo.username, TEST_PASSWORD);
const res = await leave(conv, cookie);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.success, true);
assert.equal(
await conversationExists(conv),
false,
"conversation should be deleted when last member leaves",
);
});
test("sole admin leaving auto-promotes the earliest remaining member", async () => {
const admin = await createUser("leave_admin_auto");
const m1 = await createUser("leave_m1");
const m2 = await createUser("leave_m2");
// admin joined first, then m1, then m2 (handled by createGroup ordering)
const conv = await createGroup(admin.id, [
{ userId: admin.id, isAdmin: true },
{ userId: m1.id, isAdmin: false },
{ userId: m2.id, isAdmin: false },
]);
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
const res = await leave(conv, cookie);
assert.equal(res.status, 200);
const parts = await getParticipants(conv);
assert.equal(parts.length, 2);
assert.ok(
!parts.find((p) => p.userId === admin.id),
"leaver must be removed",
);
const promoted = parts.find((p) => p.isAdmin);
assert.ok(promoted, "an admin should have been auto-promoted");
assert.equal(
promoted.userId,
m1.id,
"earliest joined remaining member should be promoted",
);
});
test("sole admin leaving with a chosen successor promotes that user", async () => {
const admin = await createUser("leave_admin_chosen");
const m1 = await createUser("leave_chosen_m1");
const m2 = await createUser("leave_chosen_m2");
const conv = await createGroup(admin.id, [
{ userId: admin.id, isAdmin: true },
{ userId: m1.id, isAdmin: false },
{ userId: m2.id, isAdmin: false },
]);
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
const res = await leave(conv, cookie, { successorId: m2.id });
assert.equal(res.status, 200);
const parts = await getParticipants(conv);
assert.equal(parts.length, 2);
const promoted = parts.find((p) => p.isAdmin);
assert.ok(promoted, "an admin should be present after handoff");
assert.equal(
promoted.userId,
m2.id,
"the chosen successor should be the new admin",
);
});
test("sole admin leaving with an invalid successor returns 400 and does not leave", async () => {
const admin = await createUser("leave_admin_bad");
const m1 = await createUser("leave_bad_m1");
const outsider = await createUser("leave_bad_outsider");
const conv = await createGroup(admin.id, [
{ userId: admin.id, isAdmin: true },
{ userId: m1.id, isAdmin: false },
]);
const cookie = await loginAndGetCookie(admin.username, TEST_PASSWORD);
const res = await leave(conv, cookie, { successorId: outsider.id });
assert.equal(res.status, 400);
const parts = await getParticipants(conv);
assert.equal(parts.length, 2, "membership should be unchanged");
const adminRow = parts.find((p) => p.userId === admin.id);
assert.ok(adminRow, "leaver should still be a member");
assert.equal(adminRow.isAdmin, true, "leaver should still be admin");
const m1Row = parts.find((p) => p.userId === m1.id);
assert.equal(m1Row.isAdmin, false, "m1 should not have been promoted");
});
test("non-admin leaving a group does not promote anyone", async () => {
const admin = await createUser("leave_keep_admin");
const m1 = await createUser("leave_nonadmin_leaver");
const m2 = await createUser("leave_nonadmin_other");
const conv = await createGroup(admin.id, [
{ userId: admin.id, isAdmin: true },
{ userId: m1.id, isAdmin: false },
{ userId: m2.id, isAdmin: false },
]);
const cookie = await loginAndGetCookie(m1.username, TEST_PASSWORD);
const res = await leave(conv, cookie);
assert.equal(res.status, 200);
const parts = await getParticipants(conv);
assert.equal(parts.length, 2);
assert.ok(
!parts.find((p) => p.userId === m1.id),
"leaver must be removed",
);
const admins = parts.filter((p) => p.isAdmin);
assert.equal(admins.length, 1, "exactly the original admin remains admin");
assert.equal(admins[0].userId, admin.id);
const m2Row = parts.find((p) => p.userId === m2.id);
assert.equal(m2Row.isAdmin, false, "non-admin member should not be promoted");
});
@@ -138,10 +138,6 @@ after(async () => {
}
for (const uid of [adminId, ...createdUserIds]) {
if (uid !== undefined) {
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]);
await pool.query(`DELETE FROM conversations WHERE created_by = $1`, [
uid,
]);
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
@@ -187,15 +183,6 @@ test("DELETE /api/users/:id with dependents returns 409 with counts", async () =
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'Note A', 'x'), ($1, 'Note B', 'y')`,
[uid],
);
const conv = await pool.query(
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Test Conv', true) RETURNING id`,
[uid],
);
const convId = conv.rows[0].id;
await pool.query(
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hello')`,
[convId, uid],
);
const res = await fetch(`${API_BASE}/api/users/${uid}`, {
method: "DELETE",
@@ -204,8 +191,6 @@ test("DELETE /api/users/:id with dependents returns 409 with counts", async () =
assert.equal(res.status, 409);
const body = await res.json();
assert.equal(body.noteCount, 2);
assert.equal(body.conversationCount, 1);
assert.equal(body.messageCount, 1);
const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [
uid,
@@ -225,14 +210,6 @@ test("DELETE /api/users/:id?force=true deletes user and writes audit log", async
await pool.query(`INSERT INTO notes (user_id, title) VALUES ($1, 'n')`, [
uid,
]);
const conv = await pool.query(
`INSERT INTO conversations (created_by, name_en, is_group) VALUES ($1, 'Conv', true) RETURNING id`,
[uid],
);
await pool.query(
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
[conv.rows[0].id, uid],
);
const res = await fetch(`${API_BASE}/api/users/${uid}?force=true`, {
method: "DELETE",
@@ -23,7 +23,6 @@ let depAppId;
let depServiceId;
let depGroupId;
let depPermissionId;
let depConvId;
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
@@ -86,7 +85,7 @@ before(async () => {
);
depPermissionId = perm.rows[0].id;
// Dependents on the user: a note, an order, a conversation, a message.
// Dependents on the user: a note and an order.
await pool.query(
`INSERT INTO notes (user_id, content) VALUES ($1, 'note for dep test')`,
[depUserId],
@@ -96,20 +95,6 @@ before(async () => {
VALUES ($1, $2, 'pending', 'dep order')`,
[depServiceId, depUserId],
);
const conv = await pool.query(
`INSERT INTO conversations (is_group, name_en, created_by)
VALUES (true, 'dep conv', $1) RETURNING id`,
[depUserId],
);
depConvId = conv.rows[0].id;
await pool.query(
`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`,
[depConvId, depUserId],
);
await pool.query(
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
[depConvId, depUserId],
);
// App dependents: group link, permission restriction, an open record.
await pool.query(
@@ -129,9 +114,6 @@ before(async () => {
});
after(async () => {
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [depUserId]);
await pool.query(`DELETE FROM conversation_participants WHERE user_id = $1`, [depUserId]);
await pool.query(`DELETE FROM conversations WHERE id = $1`, [depConvId]);
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [depUserId]);
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [depUserId]);
await pool.query(`DELETE FROM app_opens WHERE app_id = $1`, [depAppId]);
@@ -170,7 +152,7 @@ test("GET /api/services exposes orderCount", async () => {
assert.equal(target.orderCount, 1, "orderCount should be 1");
});
test("GET /api/users exposes noteCount/orderCount/conversationCount/messageCount", async () => {
test("GET /api/users exposes noteCount/orderCount", async () => {
const res = await fetch(`${API_BASE}/api/users`, {
headers: { Cookie: adminCookie },
});
@@ -180,8 +162,6 @@ test("GET /api/users exposes noteCount/orderCount/conversationCount/messageCount
assert.ok(target, "expected dep user in users list");
assert.equal(target.noteCount, 1, "noteCount should be 1");
assert.equal(target.orderCount, 1, "orderCount should be 1");
assert.equal(target.conversationCount, 1, "conversationCount should be 1");
assert.equal(target.messageCount, 1, "messageCount should be 1");
});
test("Apps list returns zero counts for entities without dependents", async () => {
@@ -248,8 +228,6 @@ test("Users list returns zero counts for users without dependents", async () =>
assert.ok(target, "expected clean user in users list");
assert.equal(target.noteCount, 0);
assert.equal(target.orderCount, 0);
assert.equal(target.conversationCount, 0);
assert.equal(target.messageCount, 0);
} finally {
await pool.query(`DELETE FROM users WHERE id = $1`, [cleanUserId]);
}