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>
152 lines
5.1 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|