feat(chat): DM-Reaktionen + Soft-Delete Backend + comment_likes realtime

- db/chat: getDmHistory liefert reactions + deletedAt; toggleDmReaction
  (WA-Toggle) + softDeleteDmMessage (nur eigene)
- Endpoints: /api/chat/reaction (Emoji-Toggle, 7er-Allowlist) +
  /api/chat/delete-message (Soft-Delete für alle)
- dm/[userId].get: aggregierte reactions + deleted im Response, Inhalt bei
  Tombstone geblankt
- Migration: comment_likes zur supabase_realtime-Publication (fixt
  Post-Kommentar-Like-Realtime, eskaliert von rebreak-native-ui)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-30 11:18:32 +02:00
parent 28887cfc49
commit 69f01c5a0c
5 changed files with 140 additions and 4 deletions

View File

@ -0,0 +1,6 @@
-- Enable Supabase Realtime für comment_likes.
-- Post-Kommentar-Likes wurden nicht live gerendert: rebreak.comment_likes war
-- nicht Teil der supabase_realtime-Publication → die PostCommentsSheet-
-- Subscription feuerte ins Leere. Gleiches Muster wie notifications
-- (20260511_enable_realtime_notifications) + chat (20260516_enable_chat_realtime).
ALTER PUBLICATION supabase_realtime ADD TABLE "rebreak"."comment_likes";

View File

@ -0,0 +1,27 @@
import { softDeleteDmMessage } from "../../db/chat";
/**
* POST /api/chat/delete-message
* Soft-Delete (Tombstone "Nachricht gelöscht") einer eigenen DM für alle.
* Body: { messageId }. Nur der Absender darf löschen (im Helper geprüft).
* Die UPDATE auf direct_messages.deleted_at propagiert via Supabase-Realtime
* an die Gegenseite. Group-Chat ist in diesem Release nicht aktiv (nur DM).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
const messageId = body?.messageId as string | undefined;
if (!messageId) {
throw createError({ statusCode: 400, message: "messageId erforderlich" });
}
const deleted = await softDeleteDmMessage(user.id, messageId);
if (!deleted) {
throw createError({
statusCode: 403,
message: "Nachricht nicht gefunden oder nicht deine",
});
}
return { deleted: true };
});

View File

@ -1,6 +1,21 @@
import { getDmHistory, markDmsAsRead } from "../../../db/chat";
import { getProfile } from "../../../db/profile";
/** Roh-Reaktionen ({userId,emoji}[]) → aggregiert pro Emoji für die UI-Pills. */
function aggregateReactions(
reactions: { userId: string; emoji: string }[],
myId: string,
): { emoji: string; count: number; mine: boolean }[] {
const map = new Map<string, { emoji: string; count: number; mine: boolean }>();
for (const r of reactions) {
const e = map.get(r.emoji) ?? { emoji: r.emoji, count: 0, mine: false };
e.count++;
if (r.userId === myId) e.mine = true;
map.set(r.emoji, e);
}
return [...map.values()];
}
/** GET /api/chat/dm/[userId]?page=1 */
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
@ -29,13 +44,16 @@ export default defineEventHandler(async (event) => {
id: m.id,
senderId: m.senderId,
receiverId: m.receiverId,
content: m.content,
// Bei Soft-Delete keinen Inhalt mehr ausliefern — Frontend zeigt Tombstone.
content: m.deletedAt ? "" : m.content,
createdAt: m.createdAt,
isOwn: m.senderId === user.id,
readAt: m.readAt,
attachmentUrl: m.attachmentUrl,
attachmentType: m.attachmentType,
attachmentName: m.attachmentName,
deleted: m.deletedAt != null,
reactions: aggregateReactions(m.reactions, user.id),
attachmentUrl: m.deletedAt ? null : m.attachmentUrl,
attachmentType: m.deletedAt ? null : m.attachmentType,
attachmentName: m.deletedAt ? null : m.attachmentName,
likesCount: m.likesCount,
replyTo: m.replyTo
? {

View File

@ -0,0 +1,33 @@
import { toggleDmReaction } from "../../db/chat";
// Erlaubte Reaktions-Emojis (= die WhatsApp-Standardleiste). Begrenzt Müll in
// der DB; eine "+"-Custom-Auswahl ist ein späteres Feature.
const ALLOWED_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏", "👏"];
/**
* POST /api/chat/reaction
* Toggle einer Emoji-Reaktion auf eine DM (WhatsApp-Verhalten: gleiches Emoji
* entfernt, anderes ersetzt). Body: { messageId, emoji }.
* Group-Chat-Reaktionen sind in diesem Release nicht aktiv (nur DM).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
const messageId = body?.messageId as string | undefined;
const emoji = body?.emoji as string | undefined;
if (!messageId || !emoji) {
throw createError({
statusCode: 400,
message: "messageId und emoji erforderlich",
});
}
if (!ALLOWED_EMOJIS.includes(emoji)) {
throw createError({ statusCode: 400, message: "Emoji nicht erlaubt" });
}
const result = await toggleDmReaction(user.id, messageId, emoji);
// emoji = null wenn die Reaktion entfernt wurde
return { emoji: result.emoji };
});

View File

@ -106,6 +106,10 @@ export async function getDmHistory(
attachmentType: true,
attachmentName: true,
likesCount: true,
deletedAt: true,
reactions: {
select: { userId: true, emoji: true },
},
replyTo: {
select: { id: true, senderId: true, content: true },
},
@ -113,6 +117,54 @@ export async function getDmHistory(
});
}
/**
* Toggle einer Emoji-Reaktion auf eine DM (WhatsApp-Verhalten):
* - kein Eintrag Reaktion anlegen
* - gleiches Emoji Reaktion entfernen (toggle off)
* - anderes Emoji Reaktion ersetzen
* Eine Reaktion pro User pro Message (PK user+message).
*/
export async function toggleDmReaction(
userId: string,
messageId: string,
emoji: string,
) {
const db = usePrisma();
const existing = await db.directMessageReaction.findUnique({
where: { userId_messageId: { userId, messageId } },
});
if (existing) {
if (existing.emoji === emoji) {
await db.directMessageReaction.delete({
where: { userId_messageId: { userId, messageId } },
});
return { emoji: null as string | null };
}
const updated = await db.directMessageReaction.update({
where: { userId_messageId: { userId, messageId } },
data: { emoji },
});
return { emoji: updated.emoji };
}
const created = await db.directMessageReaction.create({
data: { userId, messageId, emoji },
});
return { emoji: created.emoji };
}
/**
* Soft-Delete einer DM (Tombstone). Nur eigene Nachrichten löschbar.
* @returns true wenn gelöscht, false wenn nicht erlaubt/nicht gefunden.
*/
export async function softDeleteDmMessage(userId: string, messageId: string) {
const db = usePrisma();
const res = await db.directMessage.updateMany({
where: { id: messageId, senderId: userId, deletedAt: null },
data: { deletedAt: new Date() },
});
return res.count > 0;
}
export async function markDmsAsRead(senderId: string, receiverId: string) {
const db = usePrisma();
return db.directMessage.updateMany({