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