feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1
@@ -31,6 +31,14 @@ SMTP_SECURE="false"
|
|||||||
SMTP_USER=""
|
SMTP_USER=""
|
||||||
SMTP_PASS=""
|
SMTP_PASS=""
|
||||||
|
|
||||||
|
# Push: defaults to "log" (no delivery). Set PUSH_PROVIDER=fcm plus
|
||||||
|
# FCM_PROJECT_ID and GOOGLE_APPLICATION_CREDENTIALS (path to a Firebase
|
||||||
|
# service-account JSON with the "Firebase Cloud Messaging API" enabled) to
|
||||||
|
# send real notifications via FCM HTTP v1.
|
||||||
|
PUSH_PROVIDER="log"
|
||||||
|
FCM_PROJECT_ID="konfi-castle-app"
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/serviceAccount.json"
|
||||||
|
|
||||||
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
|
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
|
||||||
# use an S3-compatible bucket instead (see S3_* vars below).
|
# use an S3-compatible bucket instead (see S3_* vars below).
|
||||||
STORAGE_PROVIDER="webdav"
|
STORAGE_PROVIDER="webdav"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DeviceToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"platform" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"guestAccountId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DeviceToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DeviceToken_token_key" ON "DeviceToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+22
-4
@@ -78,6 +78,7 @@ model User {
|
|||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
chatParticipations ChatParticipant[]
|
chatParticipations ChatParticipant[]
|
||||||
|
deviceTokens DeviceToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||||
@@ -107,10 +108,27 @@ model GuestAccount {
|
|||||||
lastName String
|
lastName String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
teilnehmer Teilnehmer[]
|
teilnehmer Teilnehmer[]
|
||||||
|
deviceTokens DeviceToken[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A push-notification target (FCM registration token) bound to whoever
|
||||||
|
/// registered it — a team `User` or a `GuestAccount`. Replicated so a
|
||||||
|
/// notification can be sent from either server.
|
||||||
|
model DeviceToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
platform String
|
||||||
|
userId String?
|
||||||
|
guestAccountId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastSeenAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer
|
/// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { existsSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
|
import { PushModule } from './push/push.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { KcModule } from './kc/kc.module';
|
import { KcModule } from './kc/kc.module';
|
||||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||||
@@ -35,6 +36,7 @@ const webRoot =
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
PushModule,
|
||||||
SyncModule,
|
SyncModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
KcModule,
|
KcModule,
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ import { PrismaClient } from '../prisma/prisma.module';
|
|||||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
import { PushService } from '../push/push.service';
|
||||||
|
|
||||||
export type ChatCaller =
|
export type ChatCaller =
|
||||||
| { kind: 'user'; user: AuthenticatedUser }
|
| { kind: 'user'; user: AuthenticatedUser }
|
||||||
| { kind: 'guest'; guest: GuestJwtPayload };
|
| { kind: 'guest'; guest: GuestJwtPayload };
|
||||||
|
|
||||||
|
const CHANNEL_TITLES: Record<ChatChannelType, string> = {
|
||||||
|
[ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe',
|
||||||
|
[ChatChannelType.DIREKT]: 'Direktnachricht',
|
||||||
|
[ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam',
|
||||||
|
[ChatChannelType.BROADCAST]: 'Ankündigung',
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly sync: SyncService,
|
private readonly sync: SyncService,
|
||||||
|
private readonly push: PushService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
||||||
@@ -142,7 +151,7 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
|
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
|
||||||
await this.assertCanWrite(channelId, caller);
|
const channel = await this.assertCanWrite(channelId, caller);
|
||||||
const message = await this.prisma.chatMessage.create({
|
const message = await this.prisma.chatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
channelId,
|
channelId,
|
||||||
@@ -152,6 +161,20 @@ export class ChatService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
|
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
|
||||||
|
|
||||||
|
void this.push.notifyChannel(
|
||||||
|
channelId,
|
||||||
|
{
|
||||||
|
title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE],
|
||||||
|
body: body.length > 140 ? `${body.slice(0, 137)}…` : body,
|
||||||
|
data: { channelId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: caller.kind === 'user' ? caller.user.userId : null,
|
||||||
|
guestId: caller.kind === 'guest' ? caller.guest.guestId : null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDeviceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token!: string;
|
||||||
|
|
||||||
|
@IsIn(['web', 'android', 'ios'])
|
||||||
|
platform!: 'web' | 'android' | 'ios';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnregisterDeviceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { PushNotification, PushProvider, PushResult } from './push-provider';
|
||||||
|
|
||||||
|
interface ServiceAccount {
|
||||||
|
client_email: string;
|
||||||
|
private_key: string;
|
||||||
|
token_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Firebase Cloud Messaging HTTP v1. Auth is a service-account JWT exchanged
|
||||||
|
/// for an OAuth access token (no google-auth-library dependency — jsonwebtoken
|
||||||
|
/// is already here). Delivery failures are logged and swallowed.
|
||||||
|
export class FcmPushProvider implements PushProvider {
|
||||||
|
private readonly logger = new Logger('PushProvider');
|
||||||
|
private readonly projectId: string;
|
||||||
|
private readonly sa: ServiceAccount;
|
||||||
|
private accessToken: { value: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
this.projectId = config.getOrThrow<string>('FCM_PROJECT_ID');
|
||||||
|
const path = config.getOrThrow<string>('GOOGLE_APPLICATION_CREDENTIALS');
|
||||||
|
this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
|
||||||
|
if (tokens.length === 0) return { sent: 0, invalidTokens: [] };
|
||||||
|
let accessToken: string;
|
||||||
|
try {
|
||||||
|
accessToken = await this.getAccessToken();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`FCM auth failed: ${(err as Error).message}`);
|
||||||
|
return { sent: 0, invalidTokens: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`;
|
||||||
|
const invalidTokens: string[] = [];
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
tokens.map(async (token) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: {
|
||||||
|
token,
|
||||||
|
notification: { title: n.title, body: n.body },
|
||||||
|
data: n.data,
|
||||||
|
webpush: { fcmOptions: {} },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
sent += 1;
|
||||||
|
} else if (res.status === 404 || res.status === 400) {
|
||||||
|
invalidTokens.push(token);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`FCM send ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`FCM send error: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sent, invalidTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessToken(): Promise<string> {
|
||||||
|
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) {
|
||||||
|
return this.accessToken.value;
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const assertion = jwt.sign(
|
||||||
|
{
|
||||||
|
iss: this.sa.client_email,
|
||||||
|
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||||
|
aud: this.sa.token_uri,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600,
|
||||||
|
},
|
||||||
|
this.sa.private_key,
|
||||||
|
{ algorithm: 'RS256' },
|
||||||
|
);
|
||||||
|
const res = await fetch(this.sa.token_uri, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
assertion,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`token exchange ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as { access_token: string; expires_in: number };
|
||||||
|
this.accessToken = {
|
||||||
|
value: json.access_token,
|
||||||
|
expiresAt: Date.now() + json.expires_in * 1000,
|
||||||
|
};
|
||||||
|
return json.access_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { PushNotification, PushProvider, PushResult } from './push-provider';
|
||||||
|
|
||||||
|
/// Default provider: doesn't send, just logs. Keeps the app working before
|
||||||
|
/// FCM credentials are configured.
|
||||||
|
export class LogPushProvider implements PushProvider {
|
||||||
|
private readonly logger = new Logger('PushProvider');
|
||||||
|
|
||||||
|
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
|
||||||
|
this.logger.log(
|
||||||
|
`[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`,
|
||||||
|
);
|
||||||
|
return { sent: 0, invalidTokens: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// Abstraction over the push backend. Default is a no-send provider that only
|
||||||
|
/// logs; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging (HTTP v1).
|
||||||
|
export interface PushNotification {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
data?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushResult {
|
||||||
|
sent: number;
|
||||||
|
/// Tokens FCM reported as permanently invalid — the caller prunes them.
|
||||||
|
invalidTokens: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushProvider {
|
||||||
|
/// Best-effort: never throws for delivery problems.
|
||||||
|
sendToTokens(tokens: string[], notification: PushNotification): Promise<PushResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER');
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { PushService } from './push.service';
|
||||||
|
import { RegisterDeviceDto, UnregisterDeviceDto } from './dto/register-device.dto';
|
||||||
|
// Import the util directly (not via chat.service) to keep the module graph acyclic.
|
||||||
|
import { resolveChatCaller } from '../chat/caller.util';
|
||||||
|
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||||
|
|
||||||
|
type PushRequest = AuthenticatedRequest & {
|
||||||
|
user?: AuthenticatedRequest['user'] | GuestJwtPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller('push')
|
||||||
|
export class PushController {
|
||||||
|
constructor(private readonly push: PushService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||||
|
register(@Body() dto: RegisterDeviceDto, @Req() req: PushRequest) {
|
||||||
|
return this.push.register(dto.token, dto.platform, resolveChatCaller(req.user!));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('unregister')
|
||||||
|
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||||
|
unregister(@Body() dto: UnregisterDeviceDto) {
|
||||||
|
return this.push.unregister(dto.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PUSH_PROVIDER } from './push-provider';
|
||||||
|
import { LogPushProvider } from './log-push.provider';
|
||||||
|
import { FcmPushProvider } from './fcm-push.provider';
|
||||||
|
import { PushService } from './push.service';
|
||||||
|
import { PushController } from './push.controller';
|
||||||
|
|
||||||
|
/// Global so ChatService can inject PushService. Provider defaults to
|
||||||
|
/// log-only; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging.
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [PushController],
|
||||||
|
providers: [
|
||||||
|
PushService,
|
||||||
|
{
|
||||||
|
provide: PUSH_PROVIDER,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) =>
|
||||||
|
config.get<string>('PUSH_PROVIDER') === 'fcm'
|
||||||
|
? new FcmPushProvider(config)
|
||||||
|
: new LogPushProvider(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PushService],
|
||||||
|
})
|
||||||
|
export class PushModule {}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const SYNCED_MODELS = [
|
|||||||
'File',
|
'File',
|
||||||
'ChatChannel',
|
'ChatChannel',
|
||||||
'ChatMessage',
|
'ChatMessage',
|
||||||
|
'DeviceToken',
|
||||||
] as const;
|
] as const;
|
||||||
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user