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:
@@ -132,33 +132,11 @@ async function createNote(userId) {
|
||||
);
|
||||
}
|
||||
|
||||
async function createConversationWithMessage(creatorId) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO conversations (created_by, name_en) VALUES ($1, 'inline counts test') RETURNING id`,
|
||||
[creatorId],
|
||||
);
|
||||
const conversationId = rows[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
||||
[conversationId, creatorId],
|
||||
);
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Order matters: messages / conversations / notes / service_orders
|
||||
// must go before users (sender_id and created_by have no ON DELETE
|
||||
// rule). service_orders must also go before services (service_id is
|
||||
// ON DELETE RESTRICT).
|
||||
// Order matters: notes / service_orders must go before users
|
||||
// (user_id has no ON DELETE rule). service_orders must also go
|
||||
// before services (service_id is ON DELETE RESTRICT).
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM messages WHERE sender_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM conversations WHERE created_by = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM notes WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
@@ -259,8 +237,6 @@ const COUNT_TEXT = {
|
||||
serviceOrders: "orders",
|
||||
userNotes: "notes",
|
||||
userOrders: "orders",
|
||||
userConversations: "convos",
|
||||
userMessages: "messages",
|
||||
},
|
||||
ar: {
|
||||
appGroups: "مجموعة",
|
||||
@@ -269,8 +245,6 @@ const COUNT_TEXT = {
|
||||
serviceOrders: "طلب",
|
||||
userNotes: "ملاحظة",
|
||||
userOrders: "طلب",
|
||||
userConversations: "محادثة",
|
||||
userMessages: "رسالة",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -305,7 +279,6 @@ for (const lang of ["en", "ar"]) {
|
||||
// one so `serviceNoOrders` stays at zero orders and we can assert
|
||||
// its counts row is omitted.
|
||||
await createOrder(userWithDeps.id, serviceWithOrders.id);
|
||||
await createConversationWithMessage(userWithDeps.id);
|
||||
|
||||
// ---- Login ----
|
||||
await setLanguage(page, lang);
|
||||
@@ -401,24 +374,12 @@ for (const lang of ["en", "ar"]) {
|
||||
const userOrdersChip = page.locator(
|
||||
`[data-testid="user-counts-orders-${userWithDeps.id}"]`,
|
||||
);
|
||||
const convChip = page.locator(
|
||||
`[data-testid="user-counts-conversations-${userWithDeps.id}"]`,
|
||||
);
|
||||
const msgChip = page.locator(
|
||||
`[data-testid="user-counts-messages-${userWithDeps.id}"]`,
|
||||
);
|
||||
await expect(notesChip).toBeVisible();
|
||||
await expect(userOrdersChip).toBeVisible();
|
||||
await expect(convChip).toBeVisible();
|
||||
await expect(msgChip).toBeVisible();
|
||||
await expect(notesChip).toContainText("1");
|
||||
await expect(notesChip).toContainText(labels.userNotes);
|
||||
await expect(userOrdersChip).toContainText("1");
|
||||
await expect(userOrdersChip).toContainText(labels.userOrders);
|
||||
await expect(convChip).toContainText("1");
|
||||
await expect(convChip).toContainText(labels.userConversations);
|
||||
await expect(msgChip).toContainText("1");
|
||||
await expect(msgChip).toContainText(labels.userMessages);
|
||||
|
||||
// The bare user: row present (delete button), counts row absent.
|
||||
await expect(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user