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 { 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
|
||||
? {
|
||||
|
||||
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,
|
||||
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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user