Files
KC-APP-Server/src/push/push.service.ts
T
linus 288628f20e
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s
feat(chat): free-form GRUPPE channels with mutable participants
- ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde
  Verantwortliche/r (own KC), mixing team users and guests/Konfis as
  explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is
  not derived from Gemeinde)
- POST /chat/:kcId/gruppen to create, GET participant-candidates, and
  POST/DELETE /chat/gruppen/:channelId/participants to manage membership
  (creator, LT, or Verantwortliche/r of that KC)
- ChatGateway broadcasts chat:participants-changed on membership change
- PushService updated for nullable ChatParticipant.userId + new
  guestAccountId column
- SyncService now replicates ChatParticipant
- Prisma migration + 14 new unit tests (75/75 passing), tsc clean
- CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config
2026-09-12 13:25:48 +02:00

164 lines
5.5 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, 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),
};
}
}