Add Today dashboard foundation with permission-aware KPI summary API.

Replace hardcoded Today cards with a backend summary endpoint and composable frontend widgets filtered by org type and tab permissions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 00:07:48 +03:30
parent 893cd5b128
commit 22466490bf
16 changed files with 966 additions and 43 deletions

View File

@@ -17,6 +17,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
import { CatalogModule } from './modules/catalog/catalog.module';
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
import { TodayModule } from './modules/today/today.module';
@Module({
imports: [
@@ -37,6 +38,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
LabCaseCommentsModule,
StaffModule,
OrganizationModule,
TodayModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

@@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsISO8601, IsOptional } from 'class-validator';
export class TodaySummaryQueryDto {
@ApiPropertyOptional({
description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.',
example: '2026-07-10T00:00:00.000Z',
})
@IsOptional()
@IsISO8601()
from?: string;
@ApiPropertyOptional({
description: 'End of the local day range (ISO 8601, exclusive). Defaults to next UTC midnight.',
example: '2026-07-11T00:00:00.000Z',
})
@IsOptional()
@IsISO8601()
to?: string;
}

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
import { TodayService } from './today.service';
@ApiTags('today')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('today')
export class TodayController {
constructor(private readonly todayService: TodayService) {}
@Get('summary')
@ApiOperation({
summary:
'Permission-aware dashboard summary for the Today tab (TAB_TODAY_READ or owner)',
})
getSummary(
@Query() query: TodaySummaryQueryDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.todayService.getOrganizationIdFromUser(req.user);
return this.todayService.getSummary(req.user.id, organizationId, query);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TodayController } from './today.controller';
import { TodayService } from './today.service';
@Module({
controllers: [TodayController],
providers: [TodayService],
})
export class TodayModule {}

View File

@@ -0,0 +1,425 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { LabTaskStatus, LinkStatus } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
isUnlimitedSeats,
normalizeTabPermissions,
} from '../../common/permissions';
import {
OrganizationTypeName,
ownerPermissionsForOrgType,
} from '../../common/organization-type';
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
type TodayWidgets = {
appointmentsToday?: { count: number };
patientsToday?: { count: number };
treatmentsToday?: { count: number };
draftTreatments?: { count: number };
labCasesPendingSend?: { count: number };
casesReceivedToday?: { count: number };
tasksInProgress?: { count: number };
importantTasks?: { count: number };
pendingConnections?: { count: number };
seats?: { used: number; limit: number | null; unlimited: boolean };
pendingStaffInvites?: { count: number };
};
@Injectable()
export class TodayService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async getSummary(
userId: string,
organizationId: string,
query: TodaySummaryQueryDto,
) {
const membership = await this.getActiveMembership(userId, organizationId);
const permissionNames = this.resolvePermissionNames(membership);
this.assertCanViewToday(membership.isOwner, permissionNames);
const orgType = membership.organization.type.name as OrganizationTypeName;
const { from, to } = this.resolveDayRange(query);
const widgets: TodayWidgets = {};
const tasks: Promise<void>[] = [];
if (orgType === 'CLINIC') {
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
tasks.push(
this.loadAppointmentsToday(organizationId, from, to, widgets),
);
}
if (
this.canViewPatients(membership.isOwner, permissionNames) ||
this.canViewAppointments(membership.isOwner, permissionNames)
) {
tasks.push(
this.loadPatientsToday(organizationId, from, to, widgets),
);
}
if (this.canViewTreatment(membership.isOwner, permissionNames)) {
tasks.push(
this.loadTreatmentsToday(organizationId, from, to, widgets),
);
tasks.push(this.loadDraftTreatments(organizationId, widgets));
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
}
}
if (orgType === 'LAB') {
if (this.canViewCases(membership.isOwner, permissionNames)) {
tasks.push(
this.loadCasesReceivedToday(organizationId, from, to, widgets),
);
}
if (this.canViewTasks(membership.isOwner, permissionNames)) {
tasks.push(this.loadTasksInProgress(organizationId, widgets));
tasks.push(this.loadImportantTasks(organizationId, widgets));
}
}
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
tasks.push(
this.loadPendingConnections(organizationId, widgets),
);
}
if (this.canViewStaff(membership.isOwner, permissionNames)) {
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
}
await Promise.all(tasks);
return {
success: true,
data: {
generatedAt: new Date().toISOString(),
orgType,
range: { from: from.toISOString(), to: to.toISOString() },
widgets,
},
};
}
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
if (query.from && query.to) {
const from = new Date(query.from);
const to = new Date(query.to);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid date range');
}
if (to <= from) {
throw new BadRequestException('Range "to" must be after "from"');
}
return { from, to };
}
const now = new Date();
const from = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0),
);
const to = new Date(from.getTime() + 86_400_000);
return { from, to };
}
private async loadAppointmentsToday(
organizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const count = await this.prisma.appointment.count({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
},
});
widgets.appointmentsToday = { count };
}
private async loadPatientsToday(
organizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const rows = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
},
select: { patientId: true },
distinct: ['patientId'],
});
widgets.patientsToday = { count: rows.length };
}
private async loadTreatmentsToday(
organizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const count = await this.prisma.treatment.count({
where: {
organizationId,
treatmentAt: { gte: from, lt: to },
},
});
widgets.treatmentsToday = { count };
}
private async loadDraftTreatments(organizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.treatment.count({
where: {
organizationId,
details: { none: {} },
},
});
widgets.draftTreatments = { count };
}
private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCase.count({
where: {
sentAt: null,
destinationOrganizationId: { not: null },
treatment: { organizationId },
},
});
widgets.labCasesPendingSend = { count };
}
private async loadCasesReceivedToday(
labOrganizationId: string,
from: Date,
to: Date,
widgets: TodayWidgets,
) {
const count = await this.prisma.labCase.count({
where: {
sentAt: { gte: from, lt: to },
sends: { some: { organizationId: labOrganizationId } },
},
});
widgets.casesReceivedToday = { count };
}
private async loadTasksInProgress(labOrganizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCaseTask.count({
where: {
status: LabTaskStatus.IN_PROGRESS,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
});
widgets.tasksInProgress = { count };
}
private async loadImportantTasks(labOrganizationId: string, widgets: TodayWidgets) {
const count = await this.prisma.labCaseTask.count({
where: {
status: LabTaskStatus.IN_PROGRESS,
labCase: {
isImportant: true,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
});
widgets.importantTasks = { count };
}
private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) {
const links = await this.prisma.organizationLink.findMany({
where: {
status: LinkStatus.PENDING,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
select: { sharedDataTypes: true },
});
const count = links.filter((link) => {
const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes);
return requesterOrgId !== null && requesterOrgId !== organizationId;
}).length;
widgets.pendingConnections = { count };
}
private async loadSeats(
organizationId: string,
plan: { maxUsers: number } | null,
widgets: TodayWidgets,
) {
const used = await this.prisma.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
const maxUsers = plan?.maxUsers ?? 0;
const unlimited = isUnlimitedSeats(maxUsers);
widgets.seats = {
used,
limit: unlimited ? null : maxUsers,
unlimited,
};
}
private async loadPendingStaffInvites(organizationId: string, widgets: TodayWidgets) {
const members = await this.prisma.membership.findMany({
where: { organizationId, isOwner: false, isActive: false },
include: {
invitations: {
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
const count = members.filter((member) => {
const invitation = member.invitations[0];
if (!invitation || invitation.acceptedAt || invitation.revokedAt) {
return false;
}
return invitation.expiresAt.getTime() > Date.now();
}).length;
widgets.pendingStaffInvites = { count };
}
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
return null;
}
const requester = (sharedDataTypes as { requesterOrganizationId?: unknown })
.requesterOrganizationId;
return typeof requester === 'string' ? requester : null;
}
private async getActiveMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
organization: {
include: {
type: true,
plan: true,
},
},
permissions: { include: { permission: true } },
},
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
return membership;
}
private resolvePermissionNames(membership: {
isOwner: boolean;
organization: {
planId: string | null;
type: { name: string };
};
permissions: { permission: { name: string } }[];
}): string[] {
if (membership.isOwner) {
const orgType = (membership.organization.type.name === 'LAB'
? 'LAB'
: 'CLINIC') as OrganizationTypeName;
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId));
}
return normalizeTabPermissions(
membership.permissions.map((p) => p.permission.name),
);
}
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
if (isOwner) return;
if (!permissionNames.includes('TAB_TODAY_READ')) {
throw new ForbiddenException('You do not have access to Today');
}
}
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
[
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
].includes(p),
);
}
private canViewPatients(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'].includes(p),
);
}
private canViewTreatment(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p),
);
}
private canViewCases(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_CASES_READ', 'TAB_CASES_EDIT'].includes(p),
);
}
private canViewTasks(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p),
);
}
private canManageOrganizations(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.includes('TAB_ORGANIZATIONS_EDIT');
}
private canViewStaff(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
return names.some((p) =>
['TAB_STAFF_READ', 'TAB_STAFF_EDIT'].includes(p),
);
}
}