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({