New global push/ module mirroring mail/ and files/storage/: - PushProvider abstraction; default LogPushProvider (no delivery, logs), PUSH_PROVIDER=fcm switches to FcmPushProvider — Firebase Cloud Messaging HTTP v1, authenticated by a service-account JWT exchanged for an OAuth token (no extra dependency; jsonwebtoken does the signing). Prunes tokens FCM reports as invalid. - DeviceToken model (token + platform, bound to a User or GuestAccount), migration + added to the sync log. - POST /api/push/register + /unregister (any of the three token kinds). - PushService.notifyChannel() resolves a channel's readable audience (DIREKT participants / LT / Gemeinde members + guests / whole KC for broadcast), looks up their device tokens (minus the sender), sends. - ChatService.sendMessage() fires it best-effort after persisting. New env: PUSH_PROVIDER, FCM_PROJECT_ID (default konfi-castle-app), GOOGLE_APPLICATION_CREDENTIALS. Verified against local Postgres: register a token, send a Gemeinde-group chat message from another member -> log-push logs "would push ... to 1 device". Real FCM send needs the service-account JSON. npm test 56. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { ChatChannelType, Role, SyncOperation } from '@prisma/client';
|
|
import { PrismaClient } from '../prisma/prisma.module';
|
|
import { AuthenticatedUser } from '../auth/authenticated-request';
|
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
|
import { SyncService } from '../sync/sync.service';
|
|
import { PushService } from '../push/push.service';
|
|
|
|
export type ChatCaller =
|
|
| { kind: 'user'; user: AuthenticatedUser }
|
|
| { kind: 'guest'; guest: GuestJwtPayload };
|
|
|
|
const CHANNEL_TITLES: Record<ChatChannelType, string> = {
|
|
[ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe',
|
|
[ChatChannelType.DIREKT]: 'Direktnachricht',
|
|
[ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam',
|
|
[ChatChannelType.BROADCAST]: 'Ankündigung',
|
|
};
|
|
|
|
@Injectable()
|
|
export class ChatService {
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly sync: SyncService,
|
|
private readonly push: PushService,
|
|
) {}
|
|
|
|
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
|
const channel = await this.prisma.chatChannel.create({ data: { kcId, type, gemeindeId } });
|
|
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
|
|
return channel;
|
|
}
|
|
|
|
async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) {
|
|
const existing = await this.prisma.chatChannel.findFirst({
|
|
where: {
|
|
kcId,
|
|
type: ChatChannelType.DIREKT,
|
|
AND: [
|
|
{ participants: { some: { userId: userAId } } },
|
|
{ participants: { some: { userId: userBId } } },
|
|
],
|
|
},
|
|
});
|
|
if (existing) return existing;
|
|
const channel = await this.prisma.chatChannel.create({
|
|
data: {
|
|
kcId,
|
|
type: ChatChannelType.DIREKT,
|
|
participants: { create: [{ userId: userAId }, { userId: userBId }] },
|
|
},
|
|
});
|
|
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
|
|
return channel;
|
|
}
|
|
|
|
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
|
|
if (caller.kind === 'guest') {
|
|
return this.prisma.chatChannel.findMany({
|
|
where: { kcId, type: ChatChannelType.BROADCAST },
|
|
});
|
|
}
|
|
const { user } = caller;
|
|
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
|
|
if (isLt) {
|
|
return this.prisma.chatChannel.findMany({ where: { kcId } });
|
|
}
|
|
const gemeindeIds = user.memberships
|
|
.filter((m) => m.kcId === kcId && m.gemeindeId)
|
|
.map((m) => m.gemeindeId as string);
|
|
return this.prisma.chatChannel.findMany({
|
|
where: {
|
|
kcId,
|
|
OR: [
|
|
{ type: ChatChannelType.BROADCAST },
|
|
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
|
|
{ type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
async assertCanRead(channelId: string, caller: ChatCaller) {
|
|
return this.getChannelForCallerOrThrow(channelId, caller, 'read');
|
|
}
|
|
|
|
async assertCanWrite(channelId: string, caller: ChatCaller) {
|
|
return this.getChannelForCallerOrThrow(channelId, caller, 'write');
|
|
}
|
|
|
|
private async getChannelForCallerOrThrow(
|
|
channelId: string,
|
|
caller: ChatCaller,
|
|
mode: 'read' | 'write',
|
|
) {
|
|
const channel = await this.prisma.chatChannel.findUnique({
|
|
where: { id: channelId },
|
|
include: { participants: true },
|
|
});
|
|
if (!channel) {
|
|
throw new NotFoundException('Channel not found');
|
|
}
|
|
|
|
if (caller.kind === 'guest') {
|
|
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
|
|
if (!allowed) {
|
|
throw new ForbiddenException('Guests may only read broadcast channels');
|
|
}
|
|
if (caller.guest.kcId !== channel.kcId) {
|
|
throw new ForbiddenException('Guest does not belong to this KC');
|
|
}
|
|
return channel;
|
|
}
|
|
|
|
const { user } = caller;
|
|
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
|
|
if (isLt) {
|
|
return channel;
|
|
}
|
|
if (channel.kcId && !user.memberships.some((m) => m.kcId === channel.kcId)) {
|
|
throw new ForbiddenException('Not a member of this KC');
|
|
}
|
|
|
|
switch (channel.type) {
|
|
case ChatChannelType.BROADCAST:
|
|
if (mode === 'write') {
|
|
throw new ForbiddenException('Only Leitungsteam may post broadcasts');
|
|
}
|
|
return channel;
|
|
case ChatChannelType.LT_UEBERGREIFEND:
|
|
throw new ForbiddenException('Leitungsteam-only channel');
|
|
case ChatChannelType.GEMEINDE_GRUPPE: {
|
|
const inGemeinde = user.memberships.some(
|
|
(m) => m.kcId === channel.kcId && m.gemeindeId === channel.gemeindeId,
|
|
);
|
|
if (!inGemeinde) {
|
|
throw new ForbiddenException('Not a member of this Gemeinde');
|
|
}
|
|
return channel;
|
|
}
|
|
case ChatChannelType.DIREKT: {
|
|
const isParticipant = channel.participants.some((p) => p.userId === user.userId);
|
|
if (!isParticipant) {
|
|
throw new ForbiddenException('Not a participant of this conversation');
|
|
}
|
|
return channel;
|
|
}
|
|
default:
|
|
throw new ForbiddenException('Unknown channel type');
|
|
}
|
|
}
|
|
|
|
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
|
|
const channel = await this.assertCanWrite(channelId, caller);
|
|
const message = await this.prisma.chatMessage.create({
|
|
data: {
|
|
channelId,
|
|
body,
|
|
senderUserId: caller.kind === 'user' ? caller.user.userId : null,
|
|
senderGuestId: caller.kind === 'guest' ? caller.guest.guestId : null,
|
|
},
|
|
});
|
|
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
|
|
|
|
void this.push.notifyChannel(
|
|
channelId,
|
|
{
|
|
title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE],
|
|
body: body.length > 140 ? `${body.slice(0, 137)}…` : body,
|
|
data: { channelId },
|
|
},
|
|
{
|
|
userId: caller.kind === 'user' ? caller.user.userId : null,
|
|
guestId: caller.kind === 'guest' ? caller.guest.guestId : null,
|
|
},
|
|
);
|
|
|
|
return message;
|
|
}
|
|
|
|
async listMessages(channelId: string, caller: ChatCaller) {
|
|
await this.assertCanRead(channelId, caller);
|
|
return this.prisma.chatMessage.findMany({
|
|
where: { channelId },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
}
|
|
}
|