feature: version one notification feature implemented.
This commit is contained in:
@@ -19,6 +19,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
|
||||
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||
import { TodayModule } from './modules/today/today.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,6 +42,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
||||
OrganizationModule,
|
||||
TodayModule,
|
||||
NotificationsModule,
|
||||
RealtimeModule,
|
||||
AdminModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
import { LabCaseAccessService } from './lab-case-access.service';
|
||||
|
||||
const labCaseListInclude = {
|
||||
@@ -96,6 +97,7 @@ export class CasesService {
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
private readonly labCaseAccess: LabCaseAccessService,
|
||||
) {}
|
||||
|
||||
@@ -385,6 +387,14 @@ export class CasesService {
|
||||
type: LabCaseActivityType.CASE_IMPORTANT,
|
||||
actorUserId,
|
||||
});
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrganizationId,
|
||||
type: UserNotificationType.CASE_IMPORTANT,
|
||||
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
||||
@@ -476,6 +486,17 @@ export class CasesService {
|
||||
},
|
||||
});
|
||||
|
||||
if (assigneeUserId && assigneeUserId !== actorUserId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrganizationId,
|
||||
type: UserNotificationType.TASK_ASSIGNED,
|
||||
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId, taskId },
|
||||
recipientUserIds: [assigneeUserId],
|
||||
});
|
||||
}
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
|
||||
const commentInclude = {
|
||||
authorUser: { select: { id: true, name: true } },
|
||||
@@ -24,6 +25,7 @@ export class LabCaseCommentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
// ---------- Lab side (TAB_TASKS_EDIT) ----------
|
||||
@@ -67,6 +69,31 @@ export class LabCaseCommentsService {
|
||||
visibleToClinic: created.visibleToClinic,
|
||||
},
|
||||
});
|
||||
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrganizationId,
|
||||
type: UserNotificationType.LAB_COMMENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_TASKS_READ',
|
||||
});
|
||||
|
||||
if (created.visibleToClinic) {
|
||||
const clinicOrgId = await this.clinicOrgIdForCase(caseId);
|
||||
if (clinicOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: clinicOrgId,
|
||||
type: UserNotificationType.LAB_COMMENT_CLINIC,
|
||||
href: `/treatment?labCaseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_TREATMENT_READ',
|
||||
labCaseIdForProviderScope: caseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
|
||||
}
|
||||
|
||||
@@ -130,6 +157,17 @@ export class LabCaseCommentsService {
|
||||
actorUserId,
|
||||
payload: { commentId: created.id },
|
||||
});
|
||||
const labOrgId = await this.labOrgIdForCase(caseId);
|
||||
if (labOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrgId,
|
||||
type: UserNotificationType.CLINIC_COMMENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||
}
|
||||
|
||||
@@ -172,6 +210,17 @@ export class LabCaseCommentsService {
|
||||
actorUserId,
|
||||
payload: { commentId: created.id },
|
||||
});
|
||||
const labOrgId = await this.labOrgIdForCase(caseId);
|
||||
if (labOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrgId,
|
||||
type: UserNotificationType.CLINIC_COMMENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||
}
|
||||
|
||||
@@ -336,4 +385,21 @@ export class LabCaseCommentsService {
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to treatment cases');
|
||||
}
|
||||
|
||||
private async labOrgIdForCase(caseId: string): Promise<string | null> {
|
||||
const send = await this.prisma.labCaseSend.findFirst({
|
||||
where: { labCaseId: caseId },
|
||||
orderBy: { sentAt: 'asc' },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
return send?.organizationId ?? null;
|
||||
}
|
||||
|
||||
private async clinicOrgIdForCase(caseId: string): Promise<string | null> {
|
||||
const labCase = await this.prisma.labCase.findUnique({
|
||||
where: { id: caseId },
|
||||
select: { treatment: { select: { organizationId: true } } },
|
||||
});
|
||||
return labCase?.treatment.organizationId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsEnum, IsUUID } from 'class-validator';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { LabCaseTabReadTarget } from '@prisma/client';
|
||||
|
||||
export class MarkTabReadDto {
|
||||
@@ -10,3 +11,16 @@ export class MarkCaseReadDto {
|
||||
@IsUUID()
|
||||
labCaseId!: string;
|
||||
}
|
||||
|
||||
export class ListInboxQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto';
|
||||
import {
|
||||
ListInboxQueryDto,
|
||||
MarkCaseReadDto,
|
||||
MarkTabReadDto,
|
||||
} from './dto/notifications.dto';
|
||||
import { LabCaseActivityService } from './lab-case-activity.service';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
|
||||
@ApiTags('notifications')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly labCaseActivityService: LabCaseActivityService) {}
|
||||
constructor(
|
||||
private readonly labCaseActivityService: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
@Get('tab-counts')
|
||||
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
|
||||
@@ -69,4 +88,53 @@ export class NotificationsController {
|
||||
dto.labCaseId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('inbox')
|
||||
@ApiOperation({ summary: 'Paginated notification inbox for the current user and org' })
|
||||
listInbox(
|
||||
@Query() query: ListInboxQueryDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true, data: { items: [], nextCursor: null } };
|
||||
}
|
||||
return this.userNotifications.listInbox(req.user.id, organizationId, {
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('inbox/unread-count')
|
||||
@ApiOperation({ summary: 'Unread inbox count for the header bell' })
|
||||
inboxUnreadCount(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true, data: { count: 0 } };
|
||||
}
|
||||
return this.userNotifications.unreadCount(req.user.id, organizationId);
|
||||
}
|
||||
|
||||
@Post('inbox/read-all')
|
||||
@ApiOperation({ summary: 'Mark all inbox notifications as read for the current org' })
|
||||
markInboxReadAll(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true };
|
||||
}
|
||||
return this.userNotifications.markAllRead(req.user.id, organizationId);
|
||||
}
|
||||
|
||||
@Post('inbox/:id/read')
|
||||
@ApiOperation({ summary: 'Mark a single inbox notification as read' })
|
||||
markInboxRead(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
return this.userNotifications.markRead(req.user.id, organizationId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LabCaseActivityService } from './lab-case-activity.service';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
import { RealtimeModule } from '../../realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [RealtimeModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [LabCaseActivityService],
|
||||
exports: [LabCaseActivityService],
|
||||
providers: [LabCaseActivityService, UserNotificationService],
|
||||
exports: [LabCaseActivityService, UserNotificationService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
207
backend/src/modules/notifications/user-notification.service.ts
Normal file
207
backend/src/modules/notifications/user-notification.service.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
|
||||
import { RealtimeEmitter } from '../../realtime/realtime.emitter';
|
||||
|
||||
const membershipInclude = {
|
||||
permissions: { include: { permission: true } },
|
||||
organization: { include: { type: true, plan: true } },
|
||||
} satisfies Prisma.MembershipInclude;
|
||||
|
||||
export type UserNotificationDto = {
|
||||
id: string;
|
||||
type: UserNotificationType;
|
||||
href: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
actorUserId: string | null;
|
||||
};
|
||||
|
||||
type FanoutInput = {
|
||||
organizationId: string;
|
||||
type: UserNotificationType;
|
||||
href: string;
|
||||
actorUserId?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
/** Explicit recipient user ids (e.g. task assignee). Skips permission fan-out. */
|
||||
recipientUserIds?: string[];
|
||||
/** Required tab permission when resolving org members. */
|
||||
requiredPermission?: string;
|
||||
/** When set, only clinic members who are the treatment provider for this case. */
|
||||
labCaseIdForProviderScope?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UserNotificationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly realtime: RealtimeEmitter,
|
||||
) {}
|
||||
|
||||
async notify(input: FanoutInput): Promise<void> {
|
||||
const recipientIds = input.recipientUserIds?.length
|
||||
? [...new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId))]
|
||||
: await this.resolveRecipients(input);
|
||||
|
||||
if (recipientIds.length === 0) return;
|
||||
|
||||
const rows = await this.prisma.userNotification.createManyAndReturn({
|
||||
data: recipientIds.map((userId) => ({
|
||||
userId,
|
||||
organizationId: input.organizationId,
|
||||
type: input.type,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
payload: (input.payload ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
href: input.href,
|
||||
})),
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
const dto = this.mapRow(row);
|
||||
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.created', {
|
||||
notification: dto,
|
||||
});
|
||||
const unreadCount = await this.countUnread(row.userId, row.organizationId);
|
||||
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.unreadCount', {
|
||||
count: unreadCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async listInbox(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
options?: { limit?: number; cursor?: string },
|
||||
) {
|
||||
const limit = Math.min(Math.max(options?.limit ?? 20, 1), 50);
|
||||
const rows = await this.prisma.userNotification.findMany({
|
||||
where: { userId, organizationId },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1,
|
||||
...(options?.cursor
|
||||
? {
|
||||
cursor: { id: options.cursor },
|
||||
skip: 1,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
data: {
|
||||
items: page.map((row) => this.mapRow(row)),
|
||||
nextCursor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unreadCount(userId: string, organizationId: string) {
|
||||
const count = await this.countUnread(userId, organizationId);
|
||||
return { success: true as const, data: { count } };
|
||||
}
|
||||
|
||||
async markRead(userId: string, organizationId: string, notificationId: string) {
|
||||
const existing = await this.prisma.userNotification.findFirst({
|
||||
where: { id: notificationId, userId, organizationId },
|
||||
});
|
||||
if (!existing) {
|
||||
return { success: true as const, data: null };
|
||||
}
|
||||
if (!existing.readAt) {
|
||||
const updated = await this.prisma.userNotification.update({
|
||||
where: { id: notificationId },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
const count = await this.countUnread(userId, organizationId);
|
||||
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
|
||||
count,
|
||||
});
|
||||
return { success: true as const, data: this.mapRow(updated) };
|
||||
}
|
||||
return { success: true as const, data: this.mapRow(existing) };
|
||||
}
|
||||
|
||||
async markAllRead(userId: string, organizationId: string) {
|
||||
await this.prisma.userNotification.updateMany({
|
||||
where: { userId, organizationId, readAt: null },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
|
||||
count: 0,
|
||||
});
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
private async countUnread(userId: string, organizationId: string): Promise<number> {
|
||||
return this.prisma.userNotification.count({
|
||||
where: { userId, organizationId, readAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
private mapRow(row: {
|
||||
id: string;
|
||||
type: UserNotificationType;
|
||||
href: string;
|
||||
payload: Prisma.JsonValue;
|
||||
readAt: Date | null;
|
||||
createdAt: Date;
|
||||
actorUserId: string | null;
|
||||
}): UserNotificationDto {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
href: row.href,
|
||||
payload:
|
||||
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
|
||||
? (row.payload as Record<string, unknown>)
|
||||
: null,
|
||||
readAt: row.readAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
actorUserId: row.actorUserId,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveRecipients(input: FanoutInput): Promise<string[]> {
|
||||
if (!input.requiredPermission) return [];
|
||||
|
||||
const memberships = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: membershipInclude,
|
||||
});
|
||||
|
||||
let userIds = memberships
|
||||
.filter((m) => hasEffectivePermission(m, input.requiredPermission!))
|
||||
.map((m) => m.userId)
|
||||
.filter((id) => id !== input.actorUserId);
|
||||
|
||||
if (input.labCaseIdForProviderScope) {
|
||||
const labCase = await this.prisma.labCase.findUnique({
|
||||
where: { id: input.labCaseIdForProviderScope },
|
||||
select: {
|
||||
treatment: {
|
||||
select: {
|
||||
providerUserId: true,
|
||||
appointment: { select: { providerUserId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!labCase?.treatment) return [];
|
||||
userIds = userIds.filter((userId) =>
|
||||
isActorTreatmentProvider(labCase.treatment, userId),
|
||||
);
|
||||
}
|
||||
|
||||
return [...new Set(userIds)];
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CasesModule } from '../cases/cases.module';
|
||||
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { OrganizationController } from './organization.controller';
|
||||
import { OrganizationService } from './organization.service';
|
||||
|
||||
@Module({
|
||||
imports: [CasesModule, LabCaseCommentsModule],
|
||||
imports: [CasesModule, LabCaseCommentsModule, NotificationsModule],
|
||||
controllers: [OrganizationController],
|
||||
providers: [OrganizationService, PrismaService],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LinkStatus } from '@prisma/client';
|
||||
import { LinkStatus, UserNotificationType } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
@@ -13,6 +13,7 @@ import { ListLabCasesDto } from '../cases/dto/cases.dto';
|
||||
import { CasesService } from '../cases/cases.service';
|
||||
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
|
||||
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
|
||||
import { InviteOrganizationDto } from './dto/invite-organization.dto';
|
||||
@@ -36,6 +37,7 @@ export class OrganizationService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly casesService: CasesService,
|
||||
private readonly commentsService: LabCaseCommentsService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -269,6 +271,15 @@ export class OrganizationService {
|
||||
},
|
||||
});
|
||||
|
||||
void this.userNotifications.notify({
|
||||
organizationId: dto.targetOrganizationId,
|
||||
type: UserNotificationType.CONNECTION_REQUEST,
|
||||
href: '/organizations',
|
||||
actorUserId: userId,
|
||||
payload: { organizationLinkId: created.id, fromOrganizationId: organizationId },
|
||||
requiredPermission: 'TAB_ORGANIZATIONS_READ',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { id: created.id, status: created.status },
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StaffController } from './staff.controller';
|
||||
import { StaffService } from './staff.service';
|
||||
import { StaffWorkingHoursService } from './staff-working-hours.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [StaffController],
|
||||
providers: [StaffService, StaffWorkingHoursService, PrismaService],
|
||||
exports: [StaffWorkingHoursService],
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
||||
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
||||
@@ -17,10 +17,14 @@ import {
|
||||
} from '../../common/organization-type';
|
||||
import { InviteStaffDto } from './dto/invite-staff.dto';
|
||||
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
|
||||
@Injectable()
|
||||
export class StaffService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
@@ -217,6 +221,19 @@ export class StaffService {
|
||||
};
|
||||
});
|
||||
|
||||
void this.userNotifications.notify({
|
||||
organizationId,
|
||||
type: UserNotificationType.STAFF_INVITE,
|
||||
href: '/staff',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
membershipId: result.membershipId,
|
||||
staffInvitationId: result.invitationId,
|
||||
email,
|
||||
},
|
||||
requiredPermission: 'TAB_STAFF_READ',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date'
|
||||
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
|
||||
const taskListInclude = {
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
@@ -42,6 +43,7 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -323,6 +325,30 @@ export class TasksService {
|
||||
return result;
|
||||
});
|
||||
|
||||
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrganizationId,
|
||||
type: UserNotificationType.TASK_COMPLETED,
|
||||
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: task.labCaseId, taskId },
|
||||
requiredPermission: 'TAB_TASKS_READ',
|
||||
});
|
||||
|
||||
const clinicOrgId = task.labCase?.treatment?.organization?.id;
|
||||
if (clinicOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: clinicOrgId,
|
||||
type: UserNotificationType.TASK_COMPLETED,
|
||||
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: task.labCaseId, taskId },
|
||||
requiredPermission: 'TAB_TREATMENT_READ',
|
||||
labCaseIdForProviderScope: task.labCaseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
|
||||
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../../common/lab-case-due-date';
|
||||
import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
import {
|
||||
generateTreatmentTitle,
|
||||
normalizeTeeth,
|
||||
@@ -130,6 +131,7 @@ export class TreatmentsService {
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -815,6 +817,17 @@ export class TreatmentsService {
|
||||
}
|
||||
});
|
||||
|
||||
if (isFirstSend && labCase.destinationOrganizationId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labCase.destinationOrganizationId,
|
||||
type: UserNotificationType.CASE_SENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: {
|
||||
|
||||
20
backend/src/realtime/realtime.emitter.ts
Normal file
20
backend/src/realtime/realtime.emitter.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
export function userOrgRoom(userId: string, organizationId: string): string {
|
||||
return `user:${userId}:org:${organizationId}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RealtimeEmitter {
|
||||
private server: Server | null = null;
|
||||
|
||||
setServer(server: Server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
emitToUserOrg(userId: string, organizationId: string, event: string, payload: unknown) {
|
||||
if (!this.server) return;
|
||||
this.server.to(userOrgRoom(userId, organizationId)).emit(event, payload);
|
||||
}
|
||||
}
|
||||
81
backend/src/realtime/realtime.gateway.ts
Normal file
81
backend/src/realtime/realtime.gateway.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayInit,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { RealtimeEmitter, userOrgRoom } from './realtime.emitter';
|
||||
|
||||
type AccessPayload = {
|
||||
sub?: string;
|
||||
organizationId?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/realtime',
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3001',
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
|
||||
private readonly logger = new Logger(RealtimeGateway.name);
|
||||
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly emitter: RealtimeEmitter,
|
||||
) {}
|
||||
|
||||
afterInit(server: Server) {
|
||||
this.emitter.setServer(server);
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
const token = this.readAccessToken(client);
|
||||
if (!token) {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await this.jwt.verifyAsync<AccessPayload>(token, {
|
||||
secret: this.config.get<string>('jwt.secret'),
|
||||
});
|
||||
|
||||
if (!payload?.sub || !payload.organizationId || payload.type !== 'access') {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const room = userOrgRoom(payload.sub, payload.organizationId);
|
||||
await client.join(room);
|
||||
client.data.userId = payload.sub;
|
||||
client.data.organizationId = payload.organizationId;
|
||||
} catch (error) {
|
||||
this.logger.debug(`Realtime auth failed: ${String(error)}`);
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
private readAccessToken(client: Socket): string | null {
|
||||
const cookieHeader = client.handshake.headers.cookie;
|
||||
if (!cookieHeader) return null;
|
||||
const parts = cookieHeader.split(';');
|
||||
for (const part of parts) {
|
||||
const [rawKey, ...rest] = part.trim().split('=');
|
||||
if (rawKey === 'accessToken') {
|
||||
return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
20
backend/src/realtime/realtime.module.ts
Normal file
20
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { RealtimeEmitter } from './realtime.emitter';
|
||||
import { RealtimeGateway } from './realtime.gateway';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('jwt.secret'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [RealtimeEmitter, RealtimeGateway],
|
||||
exports: [RealtimeEmitter],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
Reference in New Issue
Block a user