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:
@@ -31,6 +31,14 @@ SMTP_SECURE="false"
|
||||
SMTP_USER=""
|
||||
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
|
||||
# use an S3-compatible bucket instead (see S3_* vars below).
|
||||
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;
|
||||
@@ -78,6 +78,7 @@ model User {
|
||||
memberships Membership[]
|
||||
messages ChatMessage[]
|
||||
chatParticipations ChatParticipant[]
|
||||
deviceTokens DeviceToken[]
|
||||
}
|
||||
|
||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||
@@ -107,10 +108,27 @@ model GuestAccount {
|
||||
lastName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
teilnehmer Teilnehmer[]
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { PushModule } from './push/push.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { KcModule } from './kc/kc.module';
|
||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||
@@ -35,6 +36,7 @@ const webRoot =
|
||||
}),
|
||||
PrismaModule,
|
||||
MailModule,
|
||||
PushModule,
|
||||
SyncModule,
|
||||
AuthModule,
|
||||
KcModule,
|
||||
|
||||
@@ -4,16 +4,25 @@ import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { PushService } from '../push/push.service';
|
||||
|
||||
export type ChatCaller =
|
||||
| { kind: 'user'; user: AuthenticatedUser }
|
||||
| { 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()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
private readonly push: PushService,
|
||||
) {}
|
||||
|
||||
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
||||
@@ -142,7 +151,7 @@ export class ChatService {
|
||||
}
|
||||
|
||||
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({
|
||||
data: {
|
||||
channelId,
|
||||
@@ -152,6 +161,20 @@ export class ChatService {
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
'ChatChannel',
|
||||
'ChatMessage',
|
||||
'DeviceToken',
|
||||
] as const;
|
||||
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user