- ring.post: log [ring] when triggered - voip-push: log [voip-push] sent on success with env (prod/sandbox) + callId - chat.ts sendDirectMessage: when attachmentType=='call' parse audio:<state>:<sec> into proper preview (Verpasster Anruf, Anruf abgelehnt, Anruf (m:ss), \u2026) so post-call push has body text instead of empty. - callkit.startOutgoingCall: skip on Android (telecomManager opens dialer UI \u2014 wrong for in-app WebRTC; iOS-CallKit only for audio-session mgmt).
253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
import { usePrisma } from "../utils/prisma";
|
|
|
|
// ─── Gruppen-Chat ─────────────────────────────────────────────────────────────
|
|
|
|
export async function getChatMessages(limit = 100) {
|
|
const db = usePrisma();
|
|
return db.chatMessage.findMany({
|
|
where: { roomId: null },
|
|
orderBy: { createdAt: "asc" },
|
|
take: limit,
|
|
select: { id: true, content: true, createdAt: true, userId: true },
|
|
});
|
|
}
|
|
|
|
export async function createChatMessage(userId: string, content: string) {
|
|
const db = usePrisma();
|
|
return db.chatMessage.create({
|
|
data: { userId, content, roomId: null },
|
|
select: { id: true, content: true, createdAt: true, userId: true },
|
|
});
|
|
}
|
|
|
|
// ─── Direktnachrichten ───────────────────────────────────────────────────────
|
|
|
|
export async function sendDirectMessage(
|
|
senderId: string,
|
|
receiverId: string,
|
|
content: string,
|
|
opts?: {
|
|
replyToId?: string;
|
|
attachmentUrl?: string;
|
|
attachmentType?: string;
|
|
attachmentName?: string;
|
|
},
|
|
) {
|
|
const db = usePrisma();
|
|
const msg = await db.directMessage.create({
|
|
data: {
|
|
senderId,
|
|
receiverId,
|
|
content,
|
|
replyToId: opts?.replyToId || null,
|
|
attachmentUrl: opts?.attachmentUrl || null,
|
|
attachmentType: opts?.attachmentType || null,
|
|
attachmentName: opts?.attachmentName || null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
content: true,
|
|
createdAt: true,
|
|
replyToId: true,
|
|
attachmentUrl: true,
|
|
attachmentType: true,
|
|
attachmentName: true,
|
|
likesCount: true,
|
|
replyTo: {
|
|
select: { id: true, senderId: true, content: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Push-Notification (fire-and-forget — blockt Response nicht)
|
|
void (async () => {
|
|
try {
|
|
const { sendChatPush, getDisplayName, truncatePreview } = await import(
|
|
"../services/push"
|
|
);
|
|
const senderName = await getDisplayName(senderId);
|
|
// Call-DM-Eintrag (attachmentType='call', attachmentName='<kind>:<state>:<durSec>')
|
|
// → schöne Preview statt leeren String. Bei verpassten/abgelehnten Calls
|
|
// ist content immer "".
|
|
let basePreview = content;
|
|
if (opts?.attachmentType === "call" && opts.attachmentName) {
|
|
const [, state, durSecStr] = opts.attachmentName.split(":");
|
|
const durSec = parseInt(durSecStr ?? "0", 10) || 0;
|
|
if (state === "unanswered") basePreview = "📞 Verpasster Anruf";
|
|
else if (state === "declined") basePreview = "📞 Anruf abgelehnt";
|
|
else if (state === "failed") basePreview = "📞 Anruf fehlgeschlagen";
|
|
else if (state === "busy") basePreview = "📞 Besetzt";
|
|
else if (state === "ended") {
|
|
const mm = Math.floor(durSec / 60);
|
|
const ss = String(durSec % 60).padStart(2, "0");
|
|
basePreview = `📞 Anruf (${mm}:${ss})`;
|
|
} else {
|
|
basePreview = "📞 Anruf";
|
|
}
|
|
} else if (!basePreview) {
|
|
basePreview =
|
|
opts?.attachmentType === "audio" ? "🎤 Sprachnachricht" :
|
|
(opts?.attachmentType === "image" || opts?.attachmentUrl) ? "📷 Foto" : "";
|
|
}
|
|
const preview = truncatePreview(basePreview);
|
|
console.log(`[dm-push] sender=${senderId} receiver=${receiverId} preview="${preview.slice(0, 30)}"`);
|
|
await sendChatPush({
|
|
receiverId,
|
|
senderName,
|
|
preview,
|
|
data: { type: "dm", targetId: senderId, messageId: msg.id },
|
|
});
|
|
} catch (err) {
|
|
console.error("[dm-push] failed:", err);
|
|
}
|
|
})();
|
|
|
|
return msg;
|
|
}
|
|
|
|
export async function getDmHistory(
|
|
userId: string,
|
|
partnerId: string,
|
|
page = 1,
|
|
limit = 50,
|
|
) {
|
|
const db = usePrisma();
|
|
const offset = (page - 1) * limit;
|
|
return db.directMessage.findMany({
|
|
where: {
|
|
OR: [
|
|
{ senderId: userId, receiverId: partnerId },
|
|
{ senderId: partnerId, receiverId: userId },
|
|
],
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
skip: offset,
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
senderId: true,
|
|
receiverId: true,
|
|
content: true,
|
|
createdAt: true,
|
|
readAt: true,
|
|
replyToId: true,
|
|
attachmentUrl: true,
|
|
attachmentType: true,
|
|
attachmentName: true,
|
|
likesCount: true,
|
|
deletedAt: true,
|
|
reactions: {
|
|
select: { userId: true, emoji: true },
|
|
},
|
|
replyTo: {
|
|
select: { id: true, senderId: true, content: true },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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({
|
|
where: { senderId, receiverId, readAt: null },
|
|
data: { readAt: new Date() },
|
|
});
|
|
}
|
|
|
|
export type DmConversationRow = {
|
|
id: string;
|
|
senderId: string;
|
|
receiverId: string;
|
|
content: string;
|
|
createdAt: Date;
|
|
readAt: Date | null;
|
|
attachmentType: string | null;
|
|
};
|
|
|
|
export async function getDmConversations(userId: string): Promise<DmConversationRow[]> {
|
|
const db = usePrisma();
|
|
// Eine Zeile pro Gesprächspartner: die jeweils NEUESTE DM. Postgres
|
|
// DISTINCT ON (partner) + ORDER BY partner, created_at DESC erledigt das
|
|
// DB-seitig in EINER Query (index-gestützt), statt 500 Rows zu ziehen und
|
|
// in JS zu deduplizieren. Spalten werden auf camelCase aliased, damit die
|
|
// Rows shape-kompatibel zur vorherigen Prisma-Selection bleiben.
|
|
return db.$queryRaw<DmConversationRow[]>`
|
|
SELECT DISTINCT ON (partner_id)
|
|
id,
|
|
sender_id AS "senderId",
|
|
receiver_id AS "receiverId",
|
|
content,
|
|
created_at AS "createdAt",
|
|
read_at AS "readAt",
|
|
attachment_type AS "attachmentType"
|
|
FROM (
|
|
SELECT *,
|
|
CASE WHEN sender_id = ${userId}::uuid THEN receiver_id ELSE sender_id END AS partner_id
|
|
FROM "rebreak"."direct_messages"
|
|
WHERE sender_id = ${userId}::uuid OR receiver_id = ${userId}::uuid
|
|
) sub
|
|
ORDER BY partner_id, created_at DESC
|
|
`;
|
|
}
|
|
|
|
export async function countUnreadDms(receiverId: string) {
|
|
const db = usePrisma();
|
|
const rows = await db.directMessage.findMany({
|
|
where: { receiverId, readAt: null },
|
|
select: { senderId: true },
|
|
});
|
|
const byPartner: Record<string, number> = {};
|
|
for (const r of rows) {
|
|
byPartner[r.senderId] = (byPartner[r.senderId] ?? 0) + 1;
|
|
}
|
|
return byPartner;
|
|
}
|