import { Inject, Injectable, Logger } from '@nestjs/common'; import { ChatChannelType, SyncOperation } from '@prisma/client'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import type { ChatCaller } from '../chat/chat.service'; import { PUSH_PROVIDER, PushNotification, PushProvider } from './push-provider'; @Injectable() export class PushService { private readonly logger = new Logger(PushService.name); constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, @Inject(PUSH_PROVIDER) private readonly provider: PushProvider, ) {} /// Upsert a device token for the current caller (team user or guest). async register(token: string, platform: string, caller: ChatCaller) { const owner = caller.kind === 'user' ? { userId: caller.user.userId, guestAccountId: null } : { userId: null, guestAccountId: caller.guest.guestId }; const row = await this.prisma.deviceToken.upsert({ where: { token }, create: { token, platform, ...owner }, update: { platform, lastSeenAt: new Date(), ...owner }, }); await this.sync.capture('DeviceToken', SyncOperation.UPDATE, row.id, row); return { ok: true }; } async unregister(token: string) { const existing = await this.prisma.deviceToken.findUnique({ where: { token } }); if (!existing) return { ok: true }; await this.prisma.deviceToken.delete({ where: { token } }); await this.sync.capture('DeviceToken', SyncOperation.DELETE, existing.id, { id: existing.id, }); return { ok: true }; } /// Fan a chat message out as a push to everyone who can read the channel, /// minus the sender. Best-effort — never throws into the caller. async notifyChannel( channelId: string, notification: PushNotification, exclude: { userId?: string | null; guestId?: string | null } = {}, ): Promise { try { const channel = await this.prisma.chatChannel.findUnique({ where: { id: channelId }, include: { participants: { select: { userId: true, guestAccountId: true } } }, }); if (!channel) return; const { userIds, guestIds } = await this.audience(channel); const tokens = await this.prisma.deviceToken.findMany({ where: { OR: [ userIds.length ? { userId: { in: userIds } } : undefined, guestIds.length ? { guestAccountId: { in: guestIds } } : undefined, ].filter(Boolean) as object[], NOT: { OR: [ exclude.userId ? { userId: exclude.userId } : undefined, exclude.guestId ? { guestAccountId: exclude.guestId } : undefined, ].filter(Boolean) as object[], }, }, select: { token: true }, }); if (tokens.length === 0) return; const { invalidTokens } = await this.provider.sendToTokens( tokens.map((t) => t.token), notification, ); if (invalidTokens.length) { await this.prisma.deviceToken.deleteMany({ where: { token: { in: invalidTokens } }, }); } } catch (err) { this.logger.warn(`notifyChannel failed: ${(err as Error).message}`); } } private async audience(channel: { kcId: string; type: ChatChannelType; gemeindeId: string | null; participants: { userId: string | null; guestAccountId: string | null }[]; }): Promise<{ userIds: string[]; guestIds: string[] }> { if (channel.type === ChatChannelType.DIREKT) { return { userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id), guestIds: [], }; } if (channel.type === ChatChannelType.GRUPPE) { return { userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id), guestIds: channel.participants .map((p) => p.guestAccountId) .filter((id): id is string => !!id), }; } const ltUsers = await this.prisma.user.findMany({ where: { OR: [ { isLeitungsteam: true }, { memberships: { some: { kcId: channel.kcId, role: 'LEITUNGSTEAM' } } }, ], }, select: { id: true }, }); const ltIds = ltUsers.map((u) => u.id); if (channel.type === ChatChannelType.LT_UEBERGREIFEND) { return { userIds: ltIds, guestIds: [] }; } if (channel.type === ChatChannelType.GEMEINDE_GRUPPE) { const [members, guests] = await Promise.all([ this.prisma.membership.findMany({ where: { kcId: channel.kcId, gemeindeId: channel.gemeindeId, status: 'ACTIVE', }, select: { userId: true }, }), this.prisma.guestAccount.findMany({ where: { kcId: channel.kcId, gemeindeId: channel.gemeindeId }, select: { id: true }, }), ]); return { userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])], guestIds: guests.map((g) => g.id), }; } // BROADCAST: everyone in the KC. const [members, guests] = await Promise.all([ this.prisma.membership.findMany({ where: { kcId: channel.kcId, status: 'ACTIVE' }, select: { userId: true }, }), this.prisma.guestAccount.findMany({ where: { kcId: channel.kcId }, select: { id: true }, }), ]); return { userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])], guestIds: guests.map((g) => g.id), }; } }