Files
KC-APP/backend/src/push/push.service.ts
T
linusandClaude Sonnet 5 cc663c7e17 feat(backend): push notifications module (FCM HTTP v1)
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>
2026-09-10 11:42:34 +02:00

152 lines
5.1 KiB
TypeScript

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<void> {
try {
const channel = await this.prisma.chatChannel.findUnique({
where: { id: channelId },
include: { participants: { select: { userId: 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 }[];
}): Promise<{ userIds: string[]; guestIds: string[] }> {
if (channel.type === ChatChannelType.DIREKT) {
return { userIds: channel.participants.map((p) => p.userId), guestIds: [] };
}
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),
};
}
}