From 69f01c5a0cefd28be43b3d9ee9db5c22503b6598 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 30 May 2026 11:18:32 +0200 Subject: [PATCH] feat(chat): DM-Reaktionen + Soft-Delete Backend + comment_likes realtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migration.sql | 6 +++ .../server/api/chat/delete-message.post.ts | 27 ++++++++++ backend/server/api/chat/dm/[userId].get.ts | 26 ++++++++-- backend/server/api/chat/reaction.post.ts | 33 ++++++++++++ backend/server/db/chat.ts | 52 +++++++++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 backend/prisma/migrations/20260530_enable_comment_likes_realtime/migration.sql create mode 100644 backend/server/api/chat/delete-message.post.ts create mode 100644 backend/server/api/chat/reaction.post.ts diff --git a/backend/prisma/migrations/20260530_enable_comment_likes_realtime/migration.sql b/backend/prisma/migrations/20260530_enable_comment_likes_realtime/migration.sql new file mode 100644 index 0000000..363cfa8 --- /dev/null +++ b/backend/prisma/migrations/20260530_enable_comment_likes_realtime/migration.sql @@ -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"; diff --git a/backend/server/api/chat/delete-message.post.ts b/backend/server/api/chat/delete-message.post.ts new file mode 100644 index 0000000..07bb153 --- /dev/null +++ b/backend/server/api/chat/delete-message.post.ts @@ -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 }; +}); diff --git a/backend/server/api/chat/dm/[userId].get.ts b/backend/server/api/chat/dm/[userId].get.ts index fcbab37..2963e0b 100644 --- a/backend/server/api/chat/dm/[userId].get.ts +++ b/backend/server/api/chat/dm/[userId].get.ts @@ -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(); + 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 ? { diff --git a/backend/server/api/chat/reaction.post.ts b/backend/server/api/chat/reaction.post.ts new file mode 100644 index 0000000..830aef0 --- /dev/null +++ b/backend/server/api/chat/reaction.post.ts @@ -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 }; +}); diff --git a/backend/server/db/chat.ts b/backend/server/db/chat.ts index 484a240..ec980c7 100644 --- a/backend/server/db/chat.ts +++ b/backend/server/db/chat.ts @@ -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({