Task #39: Send a real chat notification when someone messages a non-muted chat

Wired the chat message-send handler in artifacts/api-server/src/routes/conversations.ts to
create rows in the `notifications` table for every conversation participant who:
  - is not the sender
  - has `is_muted = false` on `conversation_participants`

Implementation details:
- Added a `createMessageNotifications` helper invoked after the new_message socket emit.
- Imported `notificationsTable` from `@workspace/db`.
- Notification content:
    * Direct chat: title = sender display name, body = message preview (≤140 chars)
    * Group chat:  title = group name (fallback chain), body = "{sender}: {preview}"
  Both Arabic and English titles/bodies are populated using the sender's
  displayNameAr/displayNameEn (with username fallback) so the existing
  bilingual notifications page renders correctly.
- type = "chat", relatedType = "conversation", relatedId = conversation id, so
  notifications can later be deep-linked to the chat.

Muted conversations are filtered at the SQL level, so no notification rows are
created for muted recipients — the existing `conversation_participants.is_muted`
flag is the single source of truth, satisfying the task acceptance criteria.

No schema changes were needed; existing `notifications` table fields cover this.
The notifications page and bell badge already read from `useListNotifications`,
so no frontend changes were required — entries appear on next refetch/navigation.

Pre-existing TypeScript errors in the api-server (stale codegen for
@workspace/api-zod and unrelated schema fields) are not introduced by this change;
the esbuild build succeeds and the server starts cleanly.
This commit is contained in:
Riyadh
2026-04-21 09:38:44 +00:00
parent ebc2e977b0
commit 16020a9e37
@@ -6,6 +6,7 @@ import {
conversationParticipantsTable,
messagesTable,
messageReadsTable,
notificationsTable,
usersTable,
} from "@workspace/db";
import { requireAuth } from "../middlewares/auth";
@@ -763,9 +764,89 @@ router.post("/conversations/:id/messages", requireAuth, async (req, res): Promis
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",
})),
);
}
router.post("/conversations/:id/read", requireAuth, async (req, res): Promise<void> => {
const params = MarkConversationReadParams.safeParse(req.params);
if (!params.success) {