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:
parent
28887cfc49
commit
69f01c5a0c
@ -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";
|
||||||
27
backend/server/api/chat/delete-message.post.ts
Normal file
27
backend/server/api/chat/delete-message.post.ts
Normal 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 };
|
||||||
|
});
|
||||||
@ -1,6 +1,21 @@
|
|||||||
import { getDmHistory, markDmsAsRead } from "../../../db/chat";
|
import { getDmHistory, markDmsAsRead } from "../../../db/chat";
|
||||||
import { getProfile } from "../../../db/profile";
|
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 */
|
/** GET /api/chat/dm/[userId]?page=1 */
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
@ -29,13 +44,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
senderId: m.senderId,
|
senderId: m.senderId,
|
||||||
receiverId: m.receiverId,
|
receiverId: m.receiverId,
|
||||||
content: m.content,
|
// Bei Soft-Delete keinen Inhalt mehr ausliefern — Frontend zeigt Tombstone.
|
||||||
|
content: m.deletedAt ? "" : m.content,
|
||||||
createdAt: m.createdAt,
|
createdAt: m.createdAt,
|
||||||
isOwn: m.senderId === user.id,
|
isOwn: m.senderId === user.id,
|
||||||
readAt: m.readAt,
|
readAt: m.readAt,
|
||||||
attachmentUrl: m.attachmentUrl,
|
deleted: m.deletedAt != null,
|
||||||
attachmentType: m.attachmentType,
|
reactions: aggregateReactions(m.reactions, user.id),
|
||||||
attachmentName: m.attachmentName,
|
attachmentUrl: m.deletedAt ? null : m.attachmentUrl,
|
||||||
|
attachmentType: m.deletedAt ? null : m.attachmentType,
|
||||||
|
attachmentName: m.deletedAt ? null : m.attachmentName,
|
||||||
likesCount: m.likesCount,
|
likesCount: m.likesCount,
|
||||||
replyTo: m.replyTo
|
replyTo: m.replyTo
|
||||||
? {
|
? {
|
||||||
|
|||||||
33
backend/server/api/chat/reaction.post.ts
Normal file
33
backend/server/api/chat/reaction.post.ts
Normal 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 };
|
||||||
|
});
|
||||||
@ -106,6 +106,10 @@ export async function getDmHistory(
|
|||||||
attachmentType: true,
|
attachmentType: true,
|
||||||
attachmentName: true,
|
attachmentName: true,
|
||||||
likesCount: true,
|
likesCount: true,
|
||||||
|
deletedAt: true,
|
||||||
|
reactions: {
|
||||||
|
select: { userId: true, emoji: true },
|
||||||
|
},
|
||||||
replyTo: {
|
replyTo: {
|
||||||
select: { id: true, senderId: true, content: true },
|
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) {
|
export async function markDmsAsRead(senderId: string, receiverId: string) {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
return db.directMessage.updateMany({
|
return db.directMessage.updateMany({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user