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
399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
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,
|
||
},
|
||
});
|
||
}
|
||
}
|