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>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user