chahinebrini 38df6fc79d feat(chat): push notifications for DMs + rooms
Backend:
- Prisma PushToken model + chat_push_enabled flag on profiles
- Migration 20260530_add_push_tokens (push_tokens table + profile flag)
- Service sendChatPush with expo-server-sdk (auto-disable invalid tokens)
- Fire-and-forget push trigger in sendDirectMessage + createRoomMessage
- API POST /users/me/push-token (upsert) + DELETE (soft-disable)

Client (rebreak-native):
- usePushTokenRegistration hook: permission, getExpoPushTokenAsync,
  Android channel 'chat', POST to backend; idempotent per session
- Notification tap deep-link: dm -> /dm?userId, room -> /room?roomId

Deploy:
- run_quiet spinner for silent altool/xcodebuild/gradle phases
- Release-notes pipeline (--notes flag / NEXT_RELEASE.md / interactive)
  archived to CHANGELOG.md, printed with ASC + Play Console links
- Default version bump ON (--no-bump opt-out), build cleanup
- NEXT_RELEASE.md with push-notification release note
2026-05-30 08:16:45 +02:00

399 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { usePrisma } from "../utils/prisma";
import { randomBytes } from "crypto";
// ─── Rooms ────────────────────────────────────────────────────────────────────
export async function listRooms(userId: string) {
const db = usePrisma();
// Public rooms + private rooms user is member of
return db.chatRoom.findMany({
where: {
OR: [
{ isPublic: true },
{ members: { some: { userId, status: "active" } } },
],
},
orderBy: { updatedAt: "desc" },
include: {
// Only include current user's membership to correctly determine isMember/myRole
members: {
where: { userId, status: "active" },
select: { userId: true, role: true },
},
messages: {
orderBy: { createdAt: "desc" },
take: 1,
select: { content: true, createdAt: true, userId: true },
},
},
});
}
export async function getRoom(roomId: string) {
const db = usePrisma();
return db.chatRoom.findUnique({
where: { id: roomId },
include: {
members: {
where: { status: "active" },
select: { userId: true, role: true, joinedAt: true },
},
},
});
}
export async function createRoom(data: {
name: string;
description?: string;
isPublic: boolean;
joinMode: string;
createdBy: string;
avatarUrl?: string;
}) {
const db = usePrisma();
const inviteCode = randomBytes(4).toString("hex");
return db.chatRoom.create({
data: {
name: data.name,
description: data.description,
isPublic: data.isPublic,
joinMode: data.joinMode,
inviteCode,
createdBy: data.createdBy,
avatarUrl: data.avatarUrl,
memberCount: 1,
members: {
create: { userId: data.createdBy, role: "owner", status: "active" },
},
},
});
}
export async function updateRoom(
roomId: string,
data: {
name?: string;
description?: string;
joinMode?: string;
avatarUrl?: string | null;
},
) {
const db = usePrisma();
return db.chatRoom.update({ where: { id: roomId }, data });
}
export async function deleteRoom(roomId: string) {
const db = usePrisma();
return db.chatRoom.delete({ where: { id: roomId } });
}
// ─── Membership ───────────────────────────────────────────────────────────────
export async function getMember(roomId: string, userId: string) {
const db = usePrisma();
return db.chatRoomMember.findUnique({
where: { roomId_userId: { roomId, userId } },
});
}
export async function getRoomMembers(roomId: string) {
const db = usePrisma();
return db.chatRoomMember.findMany({
where: { roomId, status: "active" },
orderBy: { joinedAt: "asc" },
select: { userId: true, role: true, joinedAt: true },
});
}
export async function joinRoom(
roomId: string,
userId: string,
status: "active" | "pending" = "active",
) {
const db = usePrisma();
const member = await db.chatRoomMember.upsert({
where: { roomId_userId: { roomId, userId } },
create: { roomId, userId, role: "member", status },
update: { status },
});
if (status === "active") {
await db.chatRoom.update({
where: { id: roomId },
data: { memberCount: { increment: 1 } },
});
}
return member;
}
export async function leaveRoom(roomId: string, userId: string) {
const db = usePrisma();
await db.chatRoomMember.delete({
where: { roomId_userId: { roomId, userId } },
});
await db.chatRoom.update({
where: { id: roomId },
data: { memberCount: { decrement: 1 } },
});
}
export async function approveRequest(roomId: string, userId: string) {
const db = usePrisma();
await db.chatRoomMember.update({
where: { roomId_userId: { roomId, userId } },
data: { status: "active" },
});
await db.chatRoom.update({
where: { id: roomId },
data: { memberCount: { increment: 1 } },
});
}
export async function rejectRequest(roomId: string, userId: string) {
const db = usePrisma();
await db.chatRoomMember.delete({
where: { roomId_userId: { roomId, userId } },
});
}
export async function getPendingRequests(roomId: string) {
const db = usePrisma();
return db.chatRoomMember.findMany({
where: { roomId, status: "pending" },
orderBy: { joinedAt: "asc" },
select: { userId: true, joinedAt: true },
});
}
export async function findRoomByInviteCode(code: string) {
const db = usePrisma();
return db.chatRoom.findUnique({ where: { inviteCode: code } });
}
export async function banMember(roomId: string, userId: string) {
const db = usePrisma();
const member = await db.chatRoomMember.findUnique({
where: { roomId_userId: { roomId, userId } },
});
if (!member) return;
await db.chatRoomMember.update({
where: { roomId_userId: { roomId, userId } },
data: { status: "banned" },
});
if (member.status === "active") {
await db.chatRoom.update({
where: { id: roomId },
data: { memberCount: { decrement: 1 } },
});
}
}
export async function setMemberRole(
roomId: string,
userId: string,
role: "admin" | "member",
) {
const db = usePrisma();
return db.chatRoomMember.update({
where: { roomId_userId: { roomId, userId } },
data: { role },
});
}
// ─── Room Messages ────────────────────────────────────────────────────────────
export async function getRoomMessages(
roomId: string,
cursor?: string,
limit = 50,
) {
const db = usePrisma();
return db.chatMessage.findMany({
where: { roomId },
orderBy: { createdAt: "desc" },
take: limit,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
select: {
id: true,
userId: true,
content: true,
replyToId: true,
attachmentUrl: true,
attachmentType: true,
attachmentName: true,
likesCount: true,
createdAt: true,
replyTo: {
select: { id: true, userId: true, content: true },
},
},
});
}
export async function createRoomMessage(data: {
userId: string;
roomId: string;
content: string;
replyToId?: string;
attachmentUrl?: string;
attachmentType?: string;
attachmentName?: string;
}) {
const db = usePrisma();
const msg = await db.chatMessage.create({
data: {
userId: data.userId,
content: data.content,
roomId: data.roomId,
replyToId: data.replyToId || null,
attachmentUrl: data.attachmentUrl || null,
attachmentType: data.attachmentType || null,
attachmentName: data.attachmentName || null,
},
select: {
id: true,
userId: true,
content: true,
replyToId: true,
attachmentUrl: true,
attachmentType: true,
attachmentName: true,
likesCount: true,
createdAt: true,
replyTo: {
select: { id: true, userId: true, content: true },
},
},
});
// Bump room updatedAt
await db.chatRoom.update({
where: { id: data.roomId },
data: { updatedAt: new Date() },
});
// Push-Notifications an alle Room-Member außer dem Sender selbst (fire-and-forget)
void (async () => {
const { sendChatPush, getDisplayName, truncatePreview } = await import(
"../services/push"
);
const members = await db.chatRoomMember.findMany({
where: { roomId: data.roomId, userId: { not: data.userId } },
select: { userId: true },
});
if (members.length === 0) return;
const senderName = await getDisplayName(data.userId);
const room = await db.chatRoom.findUnique({
where: { id: data.roomId },
select: { name: true },
});
const title = room?.name ? `${senderName} · ${room.name}` : senderName;
const preview = truncatePreview(
data.content || (data.attachmentUrl ? "📎 Anhang" : ""),
);
await Promise.all(
members.map((m) =>
sendChatPush({
receiverId: m.userId,
senderName: title,
preview,
data: { type: "room", targetId: data.roomId, messageId: msg.id },
}),
),
);
})();
return msg;
}
// ─── Likes ────────────────────────────────────────────────────────────────────
export async function toggleChatMessageLike(userId: string, messageId: string) {
const db = usePrisma();
const existing = await db.chatMessageLike.findUnique({
where: { userId_messageId: { userId, messageId } },
});
if (existing) {
await db.chatMessageLike.delete({
where: { userId_messageId: { userId, messageId } },
});
await db.chatMessage.update({
where: { id: messageId },
data: { likesCount: { decrement: 1 } },
});
return false;
}
await db.chatMessageLike.create({ data: { userId, messageId } });
await db.chatMessage.update({
where: { id: messageId },
data: { likesCount: { increment: 1 } },
});
return true;
}
export async function toggleDmLike(userId: string, messageId: string) {
const db = usePrisma();
const existing = await db.directMessageLike.findUnique({
where: { userId_messageId: { userId, messageId } },
});
if (existing) {
await db.directMessageLike.delete({
where: { userId_messageId: { userId, messageId } },
});
await db.directMessage.update({
where: { id: messageId },
data: { likesCount: { decrement: 1 } },
});
return false;
}
await db.directMessageLike.create({ data: { userId, messageId } });
await db.directMessage.update({
where: { id: messageId },
data: { likesCount: { increment: 1 } },
});
return true;
}
// ─── Seed Default Groups ──────────────────────────────────────────────────────
const SYSTEM_USER = "00000000-0000-0000-0000-000000000000";
const DEFAULT_ROOMS = [
{
name: "Erfolge & Meilensteine",
description: "Teile deine Fortschritte und feiere mit der Community.",
isPublic: true,
},
{
name: "Gemeinsam stark",
description: "Der offene Raum Austausch, Motivation und Zusammenhalt.",
isPublic: true,
},
];
export async function seedDefaultRooms() {
const db = usePrisma();
const existing = await db.chatRoom.findMany({
where: { isDefault: true },
select: { id: true },
});
if (existing.length >= DEFAULT_ROOMS.length) return;
for (const room of DEFAULT_ROOMS) {
const exists = await db.chatRoom.findFirst({
where: { name: room.name, isDefault: true },
});
if (exists) continue;
await db.chatRoom.create({
data: {
name: room.name,
description: room.description,
isPublic: true,
isDefault: true,
joinMode: "open",
createdBy: SYSTEM_USER,
inviteCode: randomBytes(4).toString("hex"),
memberCount: 0,
},
});
}
}